[object Object]

← back to Cli Printing Press

feat(cli): feedback subcommand for agent-in-band friction reports

c6223b41f2f77b0defa9ac5e4a590045cb9ead03 · 2026-04-17 23:35:52 -0400 · Matt Van Horn

Every printed CLI ships 'feedback <text>' and 'feedback list'. Entries
are appended as JSON lines to ~/.<cli>-pp-cli/feedback.jsonl. When
<CLI>_FEEDBACK_ENDPOINT is set and either --send is passed or
<CLI>_FEEDBACK_AUTO_SEND=true, the entry is also POSTed upstream.

Local-only is the default so the feature is safe without configuration.
Upstream POST failures surface on stderr but do not fail the command -
the primary goal was recording the feedback, and the local write
already succeeded.

agent-context JSON surfaces feedback_endpoint_configured so
introspecting agents know whether the upstream channel exists.

Mapped from HeyGen's "send it our way - from you or your agent"
framing (2026-04-13).

Files touched

Diff

commit c6223b41f2f77b0defa9ac5e4a590045cb9ead03
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Fri Apr 17 23:35:52 2026 -0400

    feat(cli): feedback subcommand for agent-in-band friction reports
    
    Every printed CLI ships 'feedback <text>' and 'feedback list'. Entries
    are appended as JSON lines to ~/.<cli>-pp-cli/feedback.jsonl. When
    <CLI>_FEEDBACK_ENDPOINT is set and either --send is passed or
    <CLI>_FEEDBACK_AUTO_SEND=true, the entry is also POSTed upstream.
    
    Local-only is the default so the feature is safe without configuration.
    Upstream POST failures surface on stderr but do not fail the command -
    the primary goal was recording the feedback, and the local write
    already succeeded.
    
    agent-context JSON surfaces feedback_endpoint_configured so
    introspecting agents know whether the upstream channel exists.
    
    Mapped from HeyGen's "send it our way - from you or your agent"
    framing (2026-04-13).
---
 internal/generator/generator.go                    |   1 +
 internal/generator/generator_test.go               |   7 +-
 internal/generator/templates/agent_context.go.tmpl |  16 +-
 internal/generator/templates/feedback.go.tmpl      | 222 +++++++++++++++++++++
 internal/generator/templates/root.go.tmpl          |   1 +
 internal/generator/templates/skill.md.tmpl         |  14 ++
 6 files changed, 251 insertions(+), 10 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 43930667..357f69c6 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -661,6 +661,7 @@ func (g *Generator) Generate() error {
 		"agent_context.go.tmpl":  filepath.Join("internal", "cli", "agent_context.go"),
 		"profile.go.tmpl":        filepath.Join("internal", "cli", "profile.go"),
 		"deliver.go.tmpl":        filepath.Join("internal", "cli", "deliver.go"),
+		"feedback.go.tmpl":       filepath.Join("internal", "cli", "feedback.go"),
 		"config.go.tmpl":         filepath.Join("internal", "config", "config.go"),
 		"cache.go.tmpl":          filepath.Join("internal", "cache", "cache.go"),
 		"client.go.tmpl":         filepath.Join("internal", "client", "client.go"),
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index e6d9c938..0011dd4b 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -28,9 +28,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// +1 for internal/cli/agent_context.go (Cloudflare-style runtime introspection)
 		// +1 for internal/cli/profile.go (HeyGen-style named-profile system)
 		// +1 for internal/cli/deliver.go (HeyGen-style --deliver output routing)
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 38},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 42},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 42},
+		// +1 for internal/cli/feedback.go (HeyGen-style in-band agent feedback channel)
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 39},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 43},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 43},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/generator/templates/agent_context.go.tmpl b/internal/generator/templates/agent_context.go.tmpl
index 97f691b3..3f16dee0 100644
--- a/internal/generator/templates/agent_context.go.tmpl
+++ b/internal/generator/templates/agent_context.go.tmpl
@@ -22,11 +22,12 @@ const agentContextSchemaVersion = "1"
 // (2026-04-13 Wrangler post): agents can introspect the live CLI without
 // parsing --help or reading source.
 type agentContext struct {
-	SchemaVersion     string                `json:"schema_version"`
-	CLI               agentContextCLI       `json:"cli"`
-	Auth              agentContextAuth      `json:"auth"`
-	Commands          []agentContextCommand `json:"commands"`
-	AvailableProfiles []string              `json:"available_profiles"`
+	SchemaVersion               string                `json:"schema_version"`
+	CLI                         agentContextCLI       `json:"cli"`
+	Auth                        agentContextAuth      `json:"auth"`
+	Commands                    []agentContextCommand `json:"commands"`
+	AvailableProfiles           []string              `json:"available_profiles"`
+	FeedbackEndpointConfigured  bool                  `json:"feedback_endpoint_configured"`
 }
 
 type agentContextCLI struct {
@@ -101,8 +102,9 @@ func buildAgentContext(rootCmd *cobra.Command) agentContext {
 			Mode:    authMode,
 			EnvVars: envVars,
 		},
-		Commands:          collectAgentCommands(rootCmd),
-		AvailableProfiles: profiles,
+		Commands:                   collectAgentCommands(rootCmd),
+		AvailableProfiles:          profiles,
+		FeedbackEndpointConfigured: FeedbackEndpointConfigured(),
 	}
 }
 
diff --git a/internal/generator/templates/feedback.go.tmpl b/internal/generator/templates/feedback.go.tmpl
new file mode 100644
index 00000000..63457513
--- /dev/null
+++ b/internal/generator/templates/feedback.go.tmpl
@@ -0,0 +1,222 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+)
+
+// FeedbackEntry is one line in the local feedback ledger. Every run of
+// the feedback command appends one entry; upstream POST is a separate,
+// optional step.
+type FeedbackEntry struct {
+	Text      string    `json:"text"`
+	CLI       string    `json:"cli"`
+	Version   string    `json:"version"`
+	AgentID   string    `json:"agent_id,omitempty"`
+	Timestamp time.Time `json:"timestamp"`
+}
+
+const feedbackMaxTextLen = 4096
+
+func feedbackFilePath() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("resolving home dir: %w", err)
+	}
+	dir := filepath.Join(home, ".{{.Name}}-pp-cli")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return "", fmt.Errorf("creating state dir: %w", err)
+	}
+	return filepath.Join(dir, "feedback.jsonl"), nil
+}
+
+// FeedbackEndpointConfigured reports whether an upstream feedback URL
+// is available. Surfaced via agent-context so introspecting agents know
+// whether their feedback will ship upstream.
+func FeedbackEndpointConfigured() bool {
+	return os.Getenv("{{envName .Name}}_FEEDBACK_ENDPOINT") != ""
+}
+
+func feedbackEndpoint() string {
+	return os.Getenv("{{envName .Name}}_FEEDBACK_ENDPOINT")
+}
+
+func feedbackAutoSend() bool {
+	v := strings.ToLower(strings.TrimSpace(os.Getenv("{{envName .Name}}_FEEDBACK_AUTO_SEND")))
+	return v == "1" || v == "true" || v == "yes"
+}
+
+func appendFeedback(entry FeedbackEntry) error {
+	p, err := feedbackFilePath()
+	if err != nil {
+		return err
+	}
+	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+	if err != nil {
+		return fmt.Errorf("opening feedback ledger: %w", err)
+	}
+	defer f.Close()
+	return json.NewEncoder(f).Encode(entry)
+}
+
+func postFeedback(url string, entry FeedbackEntry) error {
+	body, err := json.Marshal(entry)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return fmt.Errorf("building feedback request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", "{{.Name}}-pp-cli/feedback")
+	client := &http.Client{Timeout: 15 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return fmt.Errorf("posting feedback: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("feedback endpoint returned %s", resp.Status)
+	}
+	return nil
+}
+
+func newFeedbackCmd(flags *rootFlags) *cobra.Command {
+	var useStdin bool
+	var send bool
+	cmd := &cobra.Command{
+		Use:   "feedback [text]",
+		Short: "Record feedback about this CLI (local by default; upstream opt-in)",
+		Long: `Feedback is captured locally first at ~/.{{.Name}}-pp-cli/feedback.jsonl.
+When ` + "`{{envName .Name}}_FEEDBACK_ENDPOINT`" + ` is set and either --send is
+passed or ` + "`{{envName .Name}}_FEEDBACK_AUTO_SEND=true`" + `, the entry is
+POSTed as JSON after the local write.
+
+Write what surprised you or tripped you up, not a bug report. The
+loop is: agent notices friction -> one invocation -> captured -> the
+maintainer sees it.`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			var text string
+			if useStdin {
+				data, err := io.ReadAll(cmd.InOrStdin())
+				if err != nil {
+					return fmt.Errorf("reading stdin: %w", err)
+				}
+				text = strings.TrimSpace(string(data))
+			} else if len(args) > 0 {
+				text = strings.Join(args, " ")
+			}
+			text = strings.TrimSpace(text)
+			if text == "" {
+				return fmt.Errorf("feedback text is empty (pass arguments or --stdin)")
+			}
+			truncated := false
+			if len(text) > feedbackMaxTextLen {
+				text = text[:feedbackMaxTextLen]
+				truncated = true
+			}
+
+			entry := FeedbackEntry{
+				Text:      text,
+				CLI:       "{{.Name}}-pp-cli",
+				Version:   version,
+				AgentID:   os.Getenv("AGENT_ID"),
+				Timestamp: time.Now().UTC(),
+			}
+			if err := appendFeedback(entry); err != nil {
+				return err
+			}
+
+			upstreamResult := map[string]any{"sent": false}
+			if endpoint := feedbackEndpoint(); endpoint != "" && (send || feedbackAutoSend()) {
+				if err := postFeedback(endpoint, entry); err != nil {
+					fmt.Fprintf(cmd.ErrOrStderr(), "warning: feedback upstream POST failed: %v\n", err)
+					upstreamResult["sent"] = false
+					upstreamResult["error"] = err.Error()
+				} else {
+					upstreamResult["sent"] = true
+					upstreamResult["endpoint"] = endpoint
+				}
+			}
+
+			if flags.asJSON {
+				return flags.printJSON(cmd, map[string]any{
+					"recorded":  true,
+					"truncated": truncated,
+					"upstream":  upstreamResult,
+					"entry":     entry,
+				})
+			}
+			fmt.Fprintf(cmd.OutOrStdout(), "feedback recorded locally (%d chars%s)\n", len(text), func() string {
+				if truncated {
+					return ", truncated"
+				}
+				return ""
+			}())
+			if sent, _ := upstreamResult["sent"].(bool); sent {
+				fmt.Fprintf(cmd.OutOrStdout(), "upstream POST: %v\n", upstreamResult["endpoint"])
+			}
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&useStdin, "stdin", false, "Read feedback body from stdin rather than arguments")
+	cmd.Flags().BoolVar(&send, "send", false, "POST to the configured feedback endpoint in addition to local write")
+
+	cmd.AddCommand(newFeedbackListCmd(flags))
+	return cmd
+}
+
+func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
+	var limit int
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List recent feedback entries",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			p, err := feedbackFilePath()
+			if err != nil {
+				return err
+			}
+			data, err := os.ReadFile(p)
+			if err != nil {
+				if os.IsNotExist(err) {
+					if flags.asJSON {
+						return flags.printJSON(cmd, []FeedbackEntry{})
+					}
+					return nil
+				}
+				return err
+			}
+			var entries []FeedbackEntry
+			for _, line := range strings.Split(string(data), "\n") {
+				line = strings.TrimSpace(line)
+				if line == "" {
+					continue
+				}
+				var e FeedbackEntry
+				if err := json.Unmarshal([]byte(line), &e); err != nil {
+					continue
+				}
+				entries = append(entries, e)
+			}
+			if limit > 0 && limit < len(entries) {
+				entries = entries[len(entries)-limit:]
+			}
+			return flags.printJSON(cmd, entries)
+		},
+	}
+	cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of recent entries to return")
+	return cmd
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index b128c3a5..3c76f766 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -181,6 +181,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 	rootCmd.AddCommand(newAuthCmd(&flags))
 	rootCmd.AddCommand(newAgentContextCmd(rootCmd))
 	rootCmd.AddCommand(newProfileCmd(&flags))
+	rootCmd.AddCommand(newFeedbackCmd(&flags))
 {{- if .HasAsyncJobs}}
 	rootCmd.AddCommand(newJobsCmd(&flags))
 {{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index e11f7e9e..298612d4 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -146,6 +146,20 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
 - **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
 - **Non-interactive** — never prompts, every input is a flag
 
+## Agent Feedback
+
+When you (or the agent) notice something off about this CLI, record it:
+
+```
+<cli>-pp-cli feedback "the --since flag is inclusive but docs say exclusive"
+<cli>-pp-cli feedback --stdin < notes.txt
+<cli>-pp-cli feedback list --json --limit 10
+```
+
+Entries are stored locally at `~/.<cli>-pp-cli/feedback.jsonl`. They are never POSTed unless `<CLI>_FEEDBACK_ENDPOINT` is set AND either `--send` is passed or `<CLI>_FEEDBACK_AUTO_SEND=true`. Default behavior is local-only.
+
+Write what *surprised* you, not a bug report. Short, specific, one line: that is the part that compounds.
+
 ## Output Delivery
 
 Every command accepts `--deliver <sink>`. The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:

← f6e74931 feat(cli): --deliver routes command output to file or webhoo  ·  back to Cli Printing Press  ·  feat(cli): agent_workflow_readiness scorecard dimension 3ae025f8 →