Configurable error / feedback / perf reporting for Rust CLIs built on the
harryaskham mcp-cli
stack.
A CLI builds this crate in and exposes a FeedbackConfig that its project
configuration populates. The config selects a reporting strategy — how
structured feedback is fed back to the owning project. Because the strategy is
plain serde data, a project can switch backends purely from config, with no CLI
code change.
strategy.type |
Behaviour |
|---|---|
webhook |
POST each event as JSON to a URL with an optional bearer token. The CLI is a webhook consumer: it calls the endpoint and the receiver (e.g. a caco webhook) decides what to do — create a bead, log a caco error/perf/exception, page someone, … |
caco_cli |
Shell out to the local caco CLI (caco log error / caco log perf), optionally also filing a bead. |
file |
Append one JSON line per event to a configured path (durable local log; parent dirs created). |
stderr |
Write one JSON line per event to stderr (the default). |
disabled |
Drop every event. |
The webhook strategy delivers synchronously by default; set
"blocking": false in its config for best-effort background delivery that
never stalls the CLI (bounded queue; queued events are flushed when the reporter
is dropped).
token_env reads the bearer token from an environment variable at runtime, so
secrets never land in committed config. (token accepts an inline value when you
must.)
use feedback_cli::{FeedbackConfig, FeedbackEvent, Metric, Reporter};
// A real CLI deserializes this from its project config; the default reports to stderr.
let reporter = Reporter::from_config(&FeedbackConfig::default());
reporter.report(&FeedbackEvent::error("startup", "failed to read config").with_detail("ENOENT"))?;
reporter.report(&FeedbackEvent::perf("build", "slow link", Metric::new("link_ms", 4200.0)))?;
# Ok::<(), feedback_cli::FeedbackError>(())Any mcp_cli::StructuredError can be reported in one call — handy for feeding
an MCP tool failure straight back:
use feedback_cli::{FeedbackConfig, Reporter};
use mcp_cli::{ErrorCategory, StructuredError};
# struct MyError;
# impl StructuredError for MyError {
# fn category(&self) -> ErrorCategory { ErrorCategory::ExecutionFailure }
# fn code(&self) -> String { "boom".into() }
# fn message(&self) -> String { "it broke".into() }
# }
let reporter = Reporter::from_config(&FeedbackConfig::default());
reporter.report_error("my-tool", &MyError)?;
# Ok::<(), feedback_cli::FeedbackError>(())The webhook strategy sends one HTTP request per event:
- method
POST(override withmethod),Content-Type: application/json; Authorization: Bearer <token>when a token is configured (tokenortoken_env);- extra
headersif configured; - body = the JSON serialization of a
FeedbackEvent.
A non-2xx response (or transport error) surfaces as FeedbackError::Http; a 2xx
is success. It is up to the receiver (e.g. a caco webhook) to decide the action.
Example error-event body:
{
"kind": "error",
"component": "build",
"summary": "linker failed",
"detail": "ld: symbol not found",
"severity": "error",
"labels": ["category:execution_failure"],
"fields": { "crate": "acme-cli" },
"fingerprint": "ld_symbol_not_found",
"project": "acme",
"timestamp_unix_ms": 1750000000000
}| field | type | notes |
|---|---|---|
kind |
"error" | "exception" | "perf" | "info" |
required; maps to the caco surface |
component |
string | required; source/subsystem |
summary |
string | required |
severity |
"info"|"warning"|"error"|"critical" |
optional |
detail |
string | optional body/stack/context |
labels |
string[] | optional; omitted when empty |
fields |
object<string,string> | optional; omitted when empty |
fingerprint |
string | optional dedupe key |
project |
string | optional |
metric |
{ name, value, unit?, threshold?, baseline? } |
present on perf events |
timestamp_unix_ms |
number (u64) | always present; ms since the Unix epoch |
Optional fields are omitted when None/empty. Suggested receiver mapping:
error/exception → create a bead and/or caco log error; perf →
caco log perf; info → log or drop. See cargo run --example report for a
live dump of these payloads.
caco ships a webhook ingress (POST /hooks/<scope>/<hook-id>) with a bead
handler, so feedback-cli can create beads with zero custom server code. Two ways
to shape the body:
A. Turnkey — payload: "caco_bead" (recommended). feedback-cli POSTs a
bead-create body (title/description/type/priority/labels) that matches
the caco bead handler's native fields directly. summary becomes the title,
detail + a structured context footer becomes the description, error/exception
events become bugs, perf/info become tasks, and severity maps to priority.
// feedback-cli config (in the host CLI's project config)
{
"component": "my-cli",
"project": "my-project",
"strategy": {
"type": "webhook",
"url": "https://<node-or-funnel-host>/hooks/my-project/feedback",
"payload": "caco_bead"
// token resolved by convention (see below) or set token_env explicitly
}
}B. Lossless — default payload: "event". feedback-cli POSTs the full
FeedbackEvent JSON; the caco hook maps it with bead.title_from: "summary"
(the raw event JSON becomes the description, labels map through).
# caco config (operator-owned), illustrative
webhooks:
port: 8444
funnel: true # opt-in Tailscale Funnel exposure
token_env: CACOPHONY_WEBHOOK_TOKEN
hooks:
feedback:
bead:
project: my-project
labels: [feedback, external]
title_from: summary # only needed for payload "event"The caco webhook requires a bearer token. feedback-cli sends
Authorization: Bearer <token> resolved in this order:
- inline
token, - explicit
token_env(the named var must be set, else it errors), - convention (when neither is set):
CACOPHONY_<PROJECT>_WEBHOOK_TOKEN(project upper-cased, non-alphanumerics →_) thenCACOPHONY_WEBHOOK_TOKEN.
So if the host sets CACOPHONY_MY_PROJECT_WEBHOOK_TOKEN (or the shared
CACOPHONY_WEBHOOK_TOKEN) to the same secret the caco webhook accepts, a config
with just type/url/payload authenticates automatically. The matching env
var names are available programmatically via conventional_token_env_vars.
For CLIs that prefer env over a config file, FeedbackConfig::from_env() builds
the config from environment variables. The webhook endpoint is resolved in this
order:
FEEDBACK_WEBHOOK_URL— used verbatim as the full endpoint.FEEDBACK_WEBHOOK_BASE_URL— a shared hook-namespace base, joined with a per-source sub-path so each project posts to its own path under one namespace (<base>/tendril,<base>/omni-cli, …). The sub-path is the first set ofFEEDBACK_WEBHOOK_HOOK,FEEDBACK_PROJECT, thenFEEDBACK_COMPONENT(with none set, it posts to the bare base). The base's trailing/and the sub-path's leading/are normalized.
With neither set, the strategy defaults to stderr. Also read:
FEEDBACK_WEBHOOK_TOKEN_ENV (env var holding the bearer token), and
FEEDBACK_COMPONENT / FEEDBACK_PROJECT (event defaults; FEEDBACK_PROJECT also
seeds the sub-path above).
This makes the recommended one token + one global hook namespace + per-project sub-path setup pure env config — each project shares the base URL and token and only varies its sub-path:
# shared across all projects (e.g. from sops):
export CACOPHONY_FEEDBACK_TOKEN="…"
export FEEDBACK_WEBHOOK_TOKEN_ENV=CACOPHONY_FEEDBACK_TOKEN
export FEEDBACK_WEBHOOK_BASE_URL="http://<node-or-funnel-host>:11300/hooks/global"
# per project — picks its own sub-path:
export FEEDBACK_PROJECT=tendril # POSTs to …/hooks/global/tendril
# or set it explicitly, independent of the event project default:
export FEEDBACK_WEBHOOK_HOOK=tendrilOpt in to turn unhandled panics into exception feedback automatically — the literal "hook into exceptions" path:
use feedback_cli::{install_panic_hook, FeedbackConfig};
fn main() {
install_panic_hook(&FeedbackConfig::from_env());
// ... the rest of your CLI. Any panic is now reported as a
// FeedbackKind::Exception event (with source location + thread) through the
// configured strategy, before the normal panic output / abort runs.
}Mirroring updatable-cli, register_feedback_tools mounts feedback_report
and feedback_status tools onto any mcp-cli ToolRouter, resolving the
config from the host context per call:
use feedback_cli::{register_feedback_tools, FeedbackConfig};
use mcp_cli::ToolRouter;
struct Ctx;
let mut router: ToolRouter<Ctx> = ToolRouter::new();
register_feedback_tools(&mut router, |_ctx: &Ctx| FeedbackConfig::from_env());
assert!(router.tool_metadata().iter().any(|t| t.name == "feedback_report"));-
webhook(default) — enables thewebhookreporting strategy (HTTPSPOSTviaureq). Disable it to drop theureq/rustlsTLS stack when a CLI only uses thestderr/caco_cli/disabledstrategies:feedback-cli = { git = "https://github.com/harryaskham/feedback-cli", default-features = false }
With the feature off,
WebhookConfig/WebhookSinkstill exist and build, but delivery returns a config error instead of sending.
This crate is part of the harryaskham ecosystem and uses the shared nix-flake +
[patch] vendor machinery: mcp-cli is pulled from github:harryaskham/* (not
vendored) and patched into the cargo build inside the nix sandbox.
nix build .#feedback-cli # build the library
nix flake check # build + unit + doc tests
nix run .#doctor # verify project conventions
nix run .#release -- patch # bump version, tag, and trigger release.ymlPlain cargo also works — the only dependency (mcp-cli) is a public git repo
fetched over HTTPS, so no SSH key or token is needed:
cargo fmt --all --check
cargo clippy --all-targets -- -D warnings
cargo testMIT.
Beyond the Rust crate, thin drop-in feedback clients live under per-language
subprojects — all POST the identical webhook payload (caco_bead or event)
to a caco bead hook with a bearer token, so any cacophony app can file beads:
web/— TypeScript (webapp), zero deps (fetch).ios/— Swift (iPhone + macOS), Foundation only.android/— Kotlin,HttpURLConnectiononly.
Each is one file + README. The caco_bead mapping (type/priority/labels) mirrors
FeedbackEvent::to_caco_bead exactly.
{ "component": "my-cli", "project": "acme", "strategy": { "type": "webhook", "url": "https://example.invalid/feedback", "token_env": "ACME_FEEDBACK_TOKEN" } }