Skip to content

Proposal: Structured Output via Action Metadata Schema #128

@jbguerraz

Description

@jbguerraz

Proposal: Structured Output via Action Metadata Schema

Summary

Extend action metadata (action.yaml) with a result schema, symmetric with existing arguments. This enables --json CLI output, web UI rendering, and API responses - all driven by declarative schema.

Motivation

Current State

Actions define input via arguments in action.yaml:

action:
  title: Add
  arguments:
    - name: package
      type: string
    - name: type
      type: string
      enum: [git, http]
      default: git

But output is undefined - actions print directly to stdout with no contract.

Problem

  1. No machine-readable output - scripts parse human text
  2. Web UI can't render results - no schema to bind components
  3. Inconsistent formats - each action formats differently
  4. No documentation - output structure is implicit

Proposal

Add result section to action metadata using JSON Schema syntax (same as arguments):

runtime: plugin
action:
  title: Show
  description: Show package dependency details

  arguments:
    - name: package
      title: Package
      description: Package name (optional, shows all if omitted)
      type: string

  result:
    type: object
    properties:
      packages:
        type: array
        description: List of package dependencies
        items:
          type: object
          properties:
            name:
              type: string
              description: Package name
            ref:
              type: string
              description: Git reference (branch, tag, commit)
            url:
              type: string
              description: Source URL
            type:
              type: string
              enum: [git, http]
            components:
              type: array
              description: Components provided by this package
              items:
                type: string
      summary:
        type: object
        properties:
          total_packages:
            type: integer
          total_components:
            type: integer

Design

Schema Location

In action.yaml alongside arguments:

action:
  title: ...
  description: ...
  arguments: [...]   # Input schema (existing)
  result: {...}      # Output schema (new)

Schema Format

Standard JSON Schema subset (matching current arguments capabilities):

Field Type Description
type string object, array, string, integer, boolean, number
properties object For type: object - nested property definitions
items object For type: array - item schema
enum array Allowed values
description string Human-readable description
required array Required property names

Global Flag

plasmactl [action] --json    # Output result as JSON
plasmactl [action] --yaml    # Output result as YAML (optional)

Behavior

Scenario Result
--json + has result schema JSON to stdout, exit 0
--json + no result schema Error: "action does not support structured output"
No flag + has result schema Human-readable output (action controls format)
No flag + no result schema Human-readable output (unchanged behavior)

Stream Separation

stdout → Structured result (JSON/YAML) when --json/--yaml
stdout → Human-readable when no flag
stderr → Progress, debug, warnings, errors (always)

Error Handling

On error with --json:

{
  "error": {
    "message": "compose.yaml not found",
    "code": "COMPOSE_NOT_FOUND"
  }
}

Exit code remains non-zero. Error schema is standard across all actions.

Implementation

Phase 1: Launchr Core

  1. Parse result field from action.yaml
  2. Add --json global flag
  3. Add result serialization in action runner
  4. Terminal suppresses decorations in JSON mode
// pkg/action/action.go
type ActionConfig struct {
    Title       string
    Description string
    Arguments   []Argument
    Result      *jsonschema.Schema  // NEW
}

// pkg/action/runner.go
func (r *Runner) Run(action Action) error {
    err := action.Execute()

    if r.jsonMode && r.actionConfig.Result != nil {
        result := action.Result()
        // Optionally validate against schema
        return json.NewEncoder(os.Stdout).Encode(result)
    }

    return err
}

Phase 2: Action Interface

Actions implement optional Result() method:

// Actions that support structured output
type ResultProvider interface {
    Result() any
}

// Example implementation
func (s *Show) Result() any {
    return s.result  // Built during Execute()
}

Phase 3: Migrate Actions

Add result schema to action.yaml files:

# model:show
result:
  type: object
  properties:
    packages:
      type: array
      items:
        $ref: "#/definitions/package"
  definitions:
    package:
      type: object
      properties:
        name: { type: string }
        ref: { type: string }
        components: { type: array, items: { type: string } }

# component:list
result:
  type: array
  items:
    type: object
    properties:
      name: { type: string }
      layer: { type: string }
      kind: { type: string }

# node:list
result:
  type: array
  items:
    type: object
    properties:
      name: { type: string }
      chassis: { type: string }
      status: { type: string }

Web Interface Integration

The schema enables automatic UI generation:

// Schema-driven rendering
function ActionResult({ schema, data }: { schema: JSONSchema, data: any }) {
  if (schema.type === 'array') {
    return <Table schema={schema.items} data={data} />;
  }
  if (schema.type === 'object') {
    return <ObjectView schema={schema} data={data} />;
  }
  return <Primitive value={data} />;
}

// Action form + result view
function ActionView({ action }: { action: ActionConfig }) {
  const [result, setResult] = useState(null);

  return (
    <>
      {/* Input form from arguments schema */}
      <ActionForm schema={action.arguments} onSubmit={runAction} />

      {/* Result view from result schema */}
      {result && <ActionResult schema={action.result} data={result} />}
    </>
  );
}

Custom components may hypothetically be specified via schema extension:

result:
  type: array
  x-component: "tree-view"  # Hint for web UI
  items:
    type: object
    properties:
      name: { type: string }
      children: { type: array, items: { $ref: "#" } }

Examples

CLI Usage

# Human-readable (default)
$ plasmactl model:show
Package: plasma-core
  ref: prepare
  url: https://projects.skilld.cloud/skilld/pla-plasma.git
  Components (15):
    interaction.applications.dashboards
    ...

# JSON for scripting
$ plasmactl model:show --json
{
  "packages": [
    {
      "name": "plasma-core",
      "ref": "prepare",
      "url": "https://projects.skilld.cloud/skilld/pla-plasma.git",
      "components": ["interaction.applications.dashboards", ...]
    }
  ]
}

# Piping to jq
$ plasmactl model:show --json | jq '.packages[].name'
"plasma-core"
"plasma-work"

# Component filtering
$ plasmactl component:list --json | jq '.[] | select(.layer == "interaction")'

Action Implementation

// show.go
type Show struct {
    action.WithLogger
    action.WithTerm

    Package string
    result  *ShowResult
}

type ShowResult struct {
    Packages []PackageInfo `json:"packages"`
    Summary  *Summary      `json:"summary,omitempty"`
}

func (s *Show) Execute() error {
    // Build result
    s.result = &ShowResult{}

    for _, dep := range cfg.Dependencies {
        pkg := PackageInfo{
            Name: dep.Name,
            Ref:  dep.Source.Ref,
            URL:  dep.Source.URL,
        }
        s.result.Packages = append(s.result.Packages, pkg)

        // Human output (skipped in JSON mode)
        if !s.Term().JSONMode() {
            fmt.Printf("Package: %s\n", dep.Name)
            fmt.Printf("  ref: %s\n", dep.Source.Ref)
        }
    }

    return nil
}

// Implement ResultProvider
func (s *Show) Result() any {
    return s.result
}

Migration Path

  1. Launchr: Add result parsing + --json flag
  2. Core actions: Add schema to *:list, *:show, *:query
  3. Documentation: Auto-generate from schema
  4. Web UI: Schema-driven result rendering
  5. Validation: Optional result validation against schema

Backward Compatibility

  • Actions without result work unchanged
  • --json on legacy actions returns clear error
  • Human-readable output unaffected
  • No changes to existing action signatures

Benefits

Feature Input (arguments) Output (result)
CLI validation ✅ Existing ✅ Optional
Web form generation ✅ Existing ✅ New
Documentation ✅ Existing ✅ New
Type safety ✅ Go binding ✅ Go binding
JSON output N/A --json flag
YAML output N/A --yaml flag (optional)

Open Questions

  1. Schema validation: Validate result against schema in dev mode?
  2. Streaming: Large results - JSON Lines format?
  3. References: Support $ref for shared definitions?
  4. Pagination: Standard pagination schema for large lists?

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions