Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The CLI does two things:
- [Advanced](#advanced)
- [Common flags](#common-flags)
- [Environment variables](#environment-variables)
- [Telling us what you are doing](#telling-us-what-you-are-doing)
- [Output formats](#output-formats)
- [Shell completion](#shell-completion)
- [Development](#development)
Expand Down Expand Up @@ -283,6 +284,7 @@ These flags are available on every operation:
| `--page-all` | Auto-paginate and stream results as NDJSON |
| `--page-limit <N>` | Max pages to fetch when auto-paginating (default `10`) |
| `-q, --quiet` | Suppress stdout output on success (errors still go to stderr) |
| `--intent <TEXT>` | Optional one-sentence description of what you are trying to do (see [Telling us what you are doing](#telling-us-what-you-are-doing)) |

### Environment variables

Expand All @@ -293,9 +295,50 @@ These flags are available on every operation:
| `ELEVENLABS_INSECURE=1` | Skip TLS verification (debugging only) |
| `ELEVENLABS_PROXY` | HTTP(S) proxy URL |
| `ELEVENLABS_TIMEOUT_SECS` | Total request timeout in seconds |
| `ELEVENLABS_AGENT_INTENT` | Default value for `--intent`, applied to every command |

Standard environment variables (`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` / `SSL_CERT_FILE`) are also honored.

### Telling us what you are doing

Most `elevenlabs` traffic comes from AI agents. Two optional inputs let an agent
say what it is doing and what it could not do, which is what tells us which
commands to build next. Both are opt-in; the CLI behaves identically without them.

**`--intent`** — why this command is running. Sent as an `X-Agent-Intent`
request header.

```bash
elevenlabs voices search --intent "pick a narrator voice for an audiobook"

# Or set it once for a whole task, so every command inherits it:
export ELEVENLABS_AGENT_INTENT="migrate the support bot to eleven_turbo_v2"
```

**`elevenlabs feedback missing-capability`** — you needed something the CLI does
not do. There is no request to attach that to, so it gets its own command:

```bash
elevenlabs feedback missing-capability \
"no way to batch-render a script to separate files per speaker"
```

#### Keep personal data out of both fields

Describe the *goal*, not the data. Resource ids (`agent_01jz…`) and
project-relative paths are fine; names, customer content, and anything you would
not want in an analytics store are not.

Two of those are enforced rather than trusted. A value longer than 500
characters, or one carrying credentials or an absolute file path, is dropped
before the request is built — `--intent` warns on stderr and the command
proceeds normally, while `feedback` fails so you can rewrite it. The rest is
guidance: contact details are deliberately not filtered, because telephony and
support capability gaps cannot be described without them.

Free text is withheld entirely server-side for zero-retention and enterprise
workspaces, which is the boundary that actually holds.

### Output formats

Use the global `--format` flag to control output. Supported values: `json` (default), `table`, `yaml`, `csv`.
Expand Down
10 changes: 9 additions & 1 deletion cli/elevenlabs/workflow/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ fn request_options() -> Option<RequestOptions> {
let mut opts = RequestOptions::new();
opts.additional_headers
.insert("X-Source".to_string(), X_SOURCE.to_string());
// The generated `<resource> <method>` commands get this header from the
// framework's global-parameter injection; hand-written commands go
// through the SDK executor instead, which that injection does not
// reach, so add it here. Already sanitised and percent-encoded.
if let Some(encoded) = super::intent::resolved_encoded() {
opts.additional_headers
.insert(super::intent::INTENT_HEADER.to_string(), encoded);
}
Some(opts)
}

Expand Down Expand Up @@ -156,7 +164,7 @@ fn api_error_message(body: &Value) -> String {
/// a 401 surfaced as "No agents found in your ElevenLabs workspace" with exit
/// code 0 — a wrong answer reported as success. `execute_request_raw` hands
/// back the status alongside the body, so we can judge it here.
fn raw_request(
pub(super) fn raw_request(
ctx: &AppContext,
method: Method,
path: &str,
Expand Down
172 changes: 172 additions & 0 deletions cli/elevenlabs/workflow/feedback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! The `feedback` command group: a channel for agents to report what the
//! CLI cannot do.
//!
//! [`super::intent`] answers "why is this command running", but the more
//! valuable signal is the one that produces no request at all — an agent
//! looked for a command, found none, and gave up. Nothing in the request
//! log can show that. The hosted MCP solves it with a virtual
//! `get_more_tools` tool; this is the CLI's version of that tool.
//!
//! Unlike the intent header, a rejected value here is an error rather than
//! a warning: the text *is* the payload, so dropping it silently would
//! report success for a command that did nothing.

use fern_cli_sdk::app::CliApp;
use fern_cli_sdk::error::CliError;
use fern_cli_sdk::openapi::AppContext;
use reqwest::Method;
use serde_json::{json, Value};

use super::util::{downcast_ctx, dry_run_flag};
use super::{api, intent};

/// First-party endpoint for CLI feedback. Sits under the existing
/// `/v1/feedback` root resource, and names the client rather than the
/// resource on purpose: it is absent from the public OpenAPI spec, reached
/// only through [`api::raw_request`], and no SDK generates against it, so
/// naming the one caller is worth more than resource purity. The `kind`
/// field in the body discriminates report types within it.
///
/// Relative, matching the generated SDK's convention.
const FEEDBACK_PATH: &str = "v1/feedback/cli";

/// Mirrors the MCP's `get_more_tools` description, which is the wording
/// that demonstrably gets agents to call it, plus the PII sentence the MCP
/// version lacks.
const LONG_ABOUT: &str = "\
Call this when the user's request cannot be completed with any available \
elevenlabs command. Describe the capability you were looking for, so it can \
inform which commands get built next. Do not call it when an existing command \
already covers the request.

Do not include personal or customer data — describe the capability, not the \
data. A description carrying credentials or a file path is rejected.";

/// Echoes the MCP's benign response, so an agent treats this as a dead end
/// to route around rather than a failure to retry.
const RECORDED_MESSAGE: &str = "Recorded. No command exists for this yet — continue with the \
available commands, or tell the user this is not supported.";

/// Assemble the report body. Pure, so the shape is testable without a
/// live `AppContext`.
fn report_body(capability: &str, intent: Option<String>) -> Value {
let mut body = json!({
"kind": "missing_capability",
"capability": capability,
"cli_version": env!("CARGO_PKG_VERSION"),
"command": "feedback.missing_capability",
});
// Plain text, not the percent-encoded header form — this is a JSON body.
if let Some(text) = intent {
body["intent"] = json!(text);
}
body
}

fn handle_missing_capability(
matches: &clap::ArgMatches,
ctx: &AppContext,
) -> Result<(), CliError> {
let _scope = api::command_scope("feedback.missing_capability");

let raw = matches
.get_one::<String>("capability")
.expect("capability is a required positional");

let capability = intent::sanitize(raw).map_err(|reason| {
CliError::Validation(format!(
"Capability description rejected because {reason}. Rewrite it and try again."
))
})?;

let body = report_body(&capability, intent::resolved_text());

// Honour the framework's global --dry-run. Without this an agent probing
// the command with --dry-run would file a real report.
if dry_run_flag(matches) {
println!("[DRY RUN] Would report missing capability: {capability}");
if let Some(text) = body.get("intent").and_then(Value::as_str) {
println!(" [DRY RUN] With intent: {text}");
}
return Ok(());
}

api::raw_request(ctx, Method::POST, FEEDBACK_PATH, Some(body), None)?;
println!("{RECORDED_MESSAGE}");
Ok(())
}

/// Register the `feedback` command group.
///
/// Registered untyped so the handler can read the framework's global
/// `--dry-run`, which the typed form does not surface — the same reason
/// `tools push` and `agents pull` use this form.
pub fn register(app: CliApp) -> CliApp {
app.command_under(
&["feedback"],
clap::Command::new("missing-capability")
.about("Report that a task could not be completed with any available command")
.long_about(LONG_ABOUT)
.arg(
clap::Arg::new("capability")
.required(true)
.help(
"What you were trying to accomplish that the available commands \
could not do. One or two sentences, max 500 characters, no \
personal data.",
),
),
Box::new(|matches, ctx| handle_missing_capability(matches, downcast_ctx(ctx)?)),
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn the_long_about_names_the_pii_rule() {
// The wording is the whole mechanism — an agent reads this and
// nothing else before deciding what to send.
assert!(LONG_ABOUT.contains("cannot be completed"));
assert!(LONG_ABOUT.contains("Do not include personal or customer data"));
assert!(LONG_ABOUT.contains("Do not call it when an existing command"));
}

#[test]
fn a_capability_carrying_credentials_is_refused_before_any_request() {
// Rejection has to happen client-side — the request would otherwise
// carry the data to the server, which is the thing being prevented.
assert!(intent::sanitize("cannot auth with sk_abc123").is_err());
assert!(intent::sanitize("cannot read /Users/jane/script.txt").is_err());
assert!(intent::sanitize("no way to batch-render per speaker").is_ok());
// Contact details are no longer refused — telephony capability gaps
// are among the most useful reports we get. See `intent`'s module docs.
assert!(intent::sanitize("cannot import +1 555 010 9999 as a number").is_ok());
}

#[test]
fn the_body_carries_the_capability_and_omits_an_absent_intent() {
let body = report_body("cannot batch-render per speaker", None);
assert_eq!(body["kind"], "missing_capability");
assert_eq!(body["capability"], "cannot batch-render per speaker");
assert_eq!(body["command"], "feedback.missing_capability");
assert_eq!(body["cli_version"], env!("CARGO_PKG_VERSION"));
assert!(body.get("intent").is_none(), "absent intent must be omitted");
}

#[test]
fn the_body_includes_the_intent_as_plain_text() {
// Percent-encoding is for the header only; encoding it here would
// land escaped text in the analytics store.
let body = report_body("no accent picker", Some("gerar diálogo".to_string()));
assert_eq!(body["intent"], "gerar diálogo");
}

#[test]
fn the_endpoint_path_is_relative() {
// `raw_request` builds on the SDK's convention; a leading slash
// produces a doubled path.
assert!(!FEEDBACK_PATH.starts_with('/'));
}
}
Loading
Loading