Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "elevenlabs-cli"
version = "1.10.0"
version = "1.11.0"
edition = "2021"
description = "CLI for the ElevenLabs API"
license = "MIT"
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ For production, prefer setting `webhook` on `create` and handling the webhook ev

## Agents as Code

Manage Conversational AI agents from local configuration files. `elevenlabs agents init` scaffolds a project; agent configs live as JSON on disk and sync to ElevenLabs. Pulled configs are stored as raw wire JSON and pushed back verbatim, so they round-trip losslessly.
Manage Conversational AI agents from local configuration files. `elevenlabs agents init` scaffolds a project; agent configs live on disk as JSON, YAML or TOML and sync to ElevenLabs. Pulled configs keep the raw wire fields and are pushed back verbatim, so they round-trip losslessly.

### Project structure

Expand All @@ -190,15 +190,15 @@ your_project/
elevenlabs agents init [path] [--override]

# Create an agent from a template (or an existing file), upload it, and register it
elevenlabs agents add <name> [--template <template>] [--output-path <path>]
elevenlabs agents add <name> [--template <template>] [--output-path <path> | --config-format json|yaml|toml]
elevenlabs agents add [name] --from-file <path>

# Show the status of locally-configured agents
elevenlabs agents status

# Sync configs with ElevenLabs (push force-overrides main + registered branches)
elevenlabs agents push [--agent <agent_id>] [--branch <name|id>] [--version-description <text>] [--dry-run]
elevenlabs agents pull [--agent <agent_id>] [--branch <name|id>] [--all-branches] [--update] [--all] [--dry-run]
elevenlabs agents pull [--agent <agent_id>] [--branch <name|id>] [--all-branches] [--update] [--all] [--config-format json|yaml|toml] [--dry-run]

# List available agent templates, or print one's full configuration
elevenlabs agents templates list
Expand All @@ -218,6 +218,14 @@ elevenlabs agents graph [config-path|agent_id|name] [--format ascii|mermaid|merm
elevenlabs agents graph --uses <id|name>
```

### Config file formats

A config file's extension sets its format: `.json`, `.yaml`/`.yml` or `.toml`. The registry files (`agents.json`, `tools.json`, `tests.json`) stay JSON, and their `config` paths can point at any of the three, so you can convert a config by hand and update its path.

New files are JSON unless you pick another format. `agents pull --config-format toml` writes new agents as TOML, and a new branch file follows the format of its agent's config. Files that already exist keep their format. `agents add --from-file support.yaml` saves the agent's config as YAML too; `--config-format` overrides that.

TOML has no `null`, so writing TOML drops null fields. For agents nothing is lost: `push` only changes the fields it sends, and a null field was unset already. What TOML can't do is clear a field, because deleting its line leaves the value on ElevenLabs unchanged. To clear one, set it to `null` in a JSON or YAML config. Some nulls are data rather than unset fields: one inside a list (an item, or a field of an object in a list of mixed values), or anywhere within a free-form value such as a tool parameter's `constant_value`. A config with one of those can't be saved as TOML; the CLI says so and names the field.

### Two surfaces in one namespace

`elevenlabs agents` holds two kinds of command, and it helps to know which you're using:
Expand Down
159 changes: 131 additions & 28 deletions cli/elevenlabs/workflow/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use fern_cli_sdk::error::CliError;
use fern_cli_sdk::openapi::AppContext;
use serde_json::{json, Value};

use super::config_format::{ConfigFormat, FORMAT_NAMES};
use super::util::{downcast_ctx, dry_run_flag, opt_string, plan_pull_action, PullAction};
use super::{api, project, settings, templates, verify};

Expand Down Expand Up @@ -335,6 +336,10 @@ struct AddArgs {
/// Create the agent from an existing config file.
#[arg(long = "from-file")]
from_file: Option<String>,
/// Format of the generated config file. Defaults to the format of
/// --from-file, else json. With --output-path, its extension decides.
#[arg(long = "config-format", value_parser = FORMAT_NAMES, conflicts_with = "output_path")]
config_format: Option<String>,
}

fn handle_add(args: AddArgs, ctx: &AppContext) -> Result<(), CliError> {
Expand Down Expand Up @@ -385,19 +390,39 @@ fn handle_add(args: AddArgs, ctx: &AppContext) -> Result<(), CliError> {
(cfg, name)
};

// Create in ElevenLabs first to obtain an ID.
println!("Creating agent '{agent_name}' in ElevenLabs...");
let agent_id = api::create_agent(ctx, &agent_config)?;
println!("Created agent in ElevenLabs with ID: {agent_id}");

// Resolve the on-disk config path (custom or generated from the name).
let config_path = match &args.output_path {
Some(path) => path.clone(),
None => project::generate_unique_filename(project::AGENT_CONFIGS_DIR, &agent_name, ".json")
None => {
let format = args
.config_format
.as_deref()
.and_then(ConfigFormat::from_name)
.or_else(|| {
args.from_file
.as_deref()
.map(|f| ConfigFormat::from_path(Path::new(f)))
})
.unwrap_or(ConfigFormat::Json);
project::generate_unique_filename(
project::AGENT_CONFIGS_DIR,
&agent_name,
format.extension(),
)
.display()
.to_string(),
.to_string()
}
};
project::write_json_in_project(&config_path, &agent_config)?;
// Render before creating: a config the format can't hold (TOML needs an
// object at the top) would otherwise leave a remote agent with no file.
ConfigFormat::from_path(Path::new(&config_path)).render(&agent_config)?;

// Create in ElevenLabs first to obtain an ID.
println!("Creating agent '{agent_name}' in ElevenLabs...");
let agent_id = api::create_agent(ctx, &agent_config)?;
println!("Created agent in ElevenLabs with ID: {agent_id}");

project::write_config_in_project(&config_path, &agent_config)?;
match &args.from_file {
Some(from_file) => println!("Created config file: {config_path} (from: {from_file})"),
None => println!(
Expand Down Expand Up @@ -868,6 +893,15 @@ fn pull_command() -> clap::Command {
.default_value("agent_configs")
.help("Directory to write config files into"),
)
.arg(
clap::Arg::new("config-format")
.long("config-format")
.value_parser(FORMAT_NAMES)
.help(
"Format of newly created config files: json (default), yaml or toml. \
Existing files keep their format; a new branch file follows its agent's",
),
)
.arg(
clap::Arg::new("update")
.long("update")
Expand Down Expand Up @@ -895,6 +929,9 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
let all_branches = matches.get_flag("all-branches");
let output_dir =
opt_string(matches, "output-dir").unwrap_or_else(|| "agent_configs".to_string());
let config_format = opt_string(matches, "config-format")
.as_deref()
.and_then(ConfigFormat::from_name);
let dry_run = dry_run_flag(matches);
let update = matches.get_flag("update");
let all = matches.get_flag("all");
Expand Down Expand Up @@ -1009,6 +1046,9 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
}

let mut processed = 0;
// Configs that couldn't be written (TOML can't hold some values). The
// rest are still registered, so a retry doesn't duplicate their files.
let mut failed = 0;
for (action, id, name, existing_idx) in &plan {
if *action == PullAction::Skip {
println!("⊘ Skipping '{name}' (already exists, use --update to overwrite)");
Expand Down Expand Up @@ -1051,7 +1091,11 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
let entry_idx = match existing_idx {
Some(i) => {
let cfg_path = registry.agents[*i].config.clone();
project::write_json_in_project(&cfg_path, &config)?;
if let Err(e) = project::write_config_in_project(&cfg_path, &config) {
println!(" ✗ Error saving '{name}': {e}");
failed += 1;
continue;
}
if let Some(v) = &version_id {
registry.agents[*i].version_id = Some(v.clone());
}
Expand All @@ -1062,10 +1106,15 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
*i
}
None => {
let cfg_path = project::generate_unique_filename(&output_dir, name, ".json")
let ext = new_config_ext(config_format, None);
let cfg_path = project::generate_unique_filename(&output_dir, name, ext)
.display()
.to_string();
project::write_json_in_project(&cfg_path, &config)?;
if let Err(e) = project::write_config_in_project(&cfg_path, &config) {
println!(" ✗ Error saving '{name}': {e}");
failed += 1;
continue;
}
registry.agents.push(project::AgentDefinition {
config: cfg_path.clone(),
id: Some(id.clone()),
Expand All @@ -1077,22 +1126,41 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
registry.agents.len() - 1
}
};
processed += 1;

// --branch: persist the branch config alongside the main one.
if let (Some(branch), Some(bid)) = (&branch, &branch_id) {
let branch_path = project::generate_unique_filename(
&output_dir,
&format!("{name}.{branch}"),
".json",
)
.display()
.to_string();
project::write_json_in_project(&branch_path, &config)?;
// A branch pulled before keeps its file (and so its format), found
// by the name or ID it was registered under, rather than gaining a
// second file next to a stale one.
let registered = registry.agents[entry_idx].branches.as_ref().and_then(|m| {
m.iter()
.find(|(key, def)| *key == branch || def.branch_id == *bid)
.map(|(key, def)| (key.clone(), def.config.clone()))
});
let (branch_key, branch_path) = match registered {
Some(found) => found,
None => (
branch.clone(),
project::generate_unique_filename(
&output_dir,
&format!("{name}.{branch}"),
new_config_ext(config_format, Some(&registry.agents[entry_idx].config)),
)
.display()
.to_string(),
),
};
if let Err(e) = project::write_config_in_project(&branch_path, &config) {
println!(" ✗ Error saving branch '{branch}' of '{name}': {e}");
failed += 1;
continue;
}
let branches = registry.agents[entry_idx]
.branches
.get_or_insert_with(Default::default);
branches.insert(
branch.clone(),
branch_key,
project::BranchDefinition {
config: branch_path.clone(),
branch_id: bid.clone(),
Expand All @@ -1103,10 +1171,22 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
}

if all_branches {
pull_all_branches(ctx, id, name, entry_idx, &mut registry, &output_dir)?;
match pull_all_branches(
ctx,
id,
name,
entry_idx,
&mut registry,
&output_dir,
config_format,
) {
Ok(n) => failed += n,
Err(e) => {
println!(" ✗ Error pulling branches of '{name}': {e}");
failed += 1;
}
}
}

processed += 1;
}

if !dry_run && processed > 0 {
Expand All @@ -1125,26 +1205,44 @@ fn handle_pull(matches: &clap::ArgMatches, ctx: &AppContext) -> Result<(), CliEr
);
}
}
if failed > 0 {
return Err(CliError::Validation(format!(
"{failed} config(s) could not be saved; see the errors above"
)));
}
Ok(())
}

/// Extension for a config file a pull creates: `--config-format` when given,
/// else the format of `agent_config` (the agent's main config, so its branch
/// files match it), else JSON.
fn new_config_ext(flag: Option<ConfigFormat>, agent_config: Option<&str>) -> &'static str {
flag.or_else(|| agent_config.map(|c| ConfigFormat::from_path(Path::new(c))))
.unwrap_or(ConfigFormat::Json)
.extension()
}

/// Pull every non-archived, non-main branch for an agent into config files
/// tracked under its `agents.json` entry. Ports v0's `pullAllBranches`.
/// Returns how many branch configs couldn't be written.
fn pull_all_branches(
ctx: &AppContext,
agent_id: &str,
agent_name: &str,
entry_idx: usize,
registry: &mut project::AgentsConfig,
output_dir: &str,
) -> Result<(), CliError> {
config_format: Option<ConfigFormat>,
) -> Result<usize, CliError> {
println!(" Fetching branches for '{agent_name}'...");
let branches = api::list_branches(ctx, agent_id, false)?;
if branches.is_empty() {
println!(" No branches found for '{agent_name}'");
return Ok(());
return Ok(0);
}

let mut failed = 0;

let parent_branch_id = registry.agents[entry_idx].branch_id.clone();
for branch in &branches {
if branch
Expand Down Expand Up @@ -1183,11 +1281,16 @@ fn pull_all_branches(
.and_then(|m| m.get(bname))
.map(|b| b.config.clone());
let branch_path = existing_path.unwrap_or_else(|| {
project::generate_unique_filename(output_dir, &format!("{agent_name}.{bname}"), ".json")
let ext = new_config_ext(config_format, Some(&registry.agents[entry_idx].config));
project::generate_unique_filename(output_dir, &format!("{agent_name}.{bname}"), ext)
.display()
.to_string()
});
project::write_json_in_project(&branch_path, &branch_config)?;
if let Err(e) = project::write_config_in_project(&branch_path, &branch_config) {
println!(" ✗ Error saving branch '{bname}': {e}");
failed += 1;
continue;
}

let map = registry.agents[entry_idx]
.branches
Expand All @@ -1211,7 +1314,7 @@ fn pull_all_branches(
if count > 0 {
println!(" {count} branch(es) stored");
}
Ok(())
Ok(failed)
}

/// Extract the on-disk config (name + conversation_config + platform_settings
Expand Down
Loading
Loading