A Better Pattern Than MCP for Agent-Friendly CLIs
There was an interesting Hacker News thread the other day about making CLIs more agent-friendly. I shared what had been working for me there, and the pattern felt useful enough to write up properly.
At a high level, it comes down to three things:
- Give the CLI a way to list the available docs.
- Give it a way to print one doc file by path.
- Give it semantic search over the docs.
For a lot of CLI workflows, that is enough. The agent can discover what exists, read the right file, and search when it is not sure where to start.
In Coasts, we ended up with a pattern that looks like this:
$ coast docs
README.md
coastfiles/
README.md
COASTFILE.md
concepts_and_terminology/
LOOKUP.md
FILESYSTEM.md
EXEC_AND_DOCKER.md
LOGS.md
$ coast docs --path concepts_and_terminology/LOOKUP.md
# Lookup
... markdown content ...
$ coast search-docs "how do I share services between instances?"
Hybrid search active (locale: en, strategy: hybrid_keyword_semantic)
1. Shared Services — shared/SERVICES.md
Databases and services can be shared across instances.
2. Volume Strategy — coastfiles/COASTFILE.md
Use isolated volume strategy for local development.
That is the whole interface.
Why this works so well
What I like about this pattern is that each piece is simple and easy to reason about.
- It is just stdout.
- It is easy for humans to test.
- It is easy for agents to invoke.
- It versions with the CLI itself.
- It still works offline once the assets are built into the binary, which also means the docs stay relevant to the exact release the user is on.
The docs start to become part of the code. They are effectively prompts for an agent trying to reason about your CLI, so you start taking them much more seriously.
The generic pattern
The pattern is this:
$ my-cli docs
- README.md
- DOC1.md
- dir2/DOC2.md
$ my-cli docs --path dir2/DOC2.md
# Contents of DOC2.md
$ my-cli search "how do I install x?"
[1] DOC1.md
"You can install x by ..."
[2] dir2/DOC2.md
"After you install..."
Keep the docs short, focused, and easy to print. That makes them easier for the agent to navigate, and it helps avoid burning through the context window on giant blobs of text.
How Coasts implements it
In Coasts, the CLI frontend talks to a daemon backend, but the same pattern would work just as well in a single CLI binary.
1. coast docs lists the tree or prints a single markdown file
The CLI side is deliberately tiny. It just sends a DocsRequest and either prints the file contents or renders the tree:
pub async fn execute(args: &DocsArgs) -> Result<()> {
let request = Request::Docs(DocsRequest {
path: args.path.clone(),
language: Some(crate::i18n_helper::cli_lang().to_string()),
});
let response = super::send_request(request).await?;
match response {
Response::Docs(resp) => {
if let Some(content) = resp.content {
println!("{content}");
return Ok(());
}
println!("{}", format_docs_tree(&resp.tree));
Ok(())
}
Response::Error(e) => bail!("{}", e.error),
_ => bail!("unexpected response"),
}
}
On the daemon side, the docs are loaded from embedded assets, so packaged binaries can still serve docs without needing the repo checked out:
pub async fn handle_docs(req: DocsRequest, state: &AppState) -> Result<DocsResponse> {
let locale = resolve_locale(req.language.as_deref(), &state.language());
let localized_docs = load_docs_for_locale(&locale)?;
let english_docs = if locale == "en" {
HashMap::new()
} else {
load_docs_for_locale("en")?
};
let tree = build_docs_tree(tree_source.keys().cloned());
if let Some(path) = req.path {
let resolved = resolve_markdown_path(&path, &localized_docs, &english_docs)
.ok_or_else(|| CoastError::state(format!("docs path '{path}' not found")))?;
let content = localized_docs
.get(&resolved)
.or_else(|| english_docs.get(&resolved))
.cloned()
.ok_or_else(|| CoastError::state(format!("resolved docs path missing: {resolved}")))?;
return Ok(DocsResponse {
locale,
tree,
path: Some(resolved),
content: Some(content),
});
}
Ok(DocsResponse {
locale,
tree,
path: None,
content: None,
})
}
This gives the agent a simple loop: list the docs, pick a file, read the markdown.
If you want to read the exact source, the two main pieces here are coast-cli/src/commands/docs.rs and coast-daemon/src/handlers/docs.rs.
2. coast search-docs does hybrid retrieval
The CLI command itself is again tiny:
pub async fn execute(args: &SearchDocsArgs) -> Result<()> {
let query = join_query(&args.query);
let request = Request::SearchDocs(SearchDocsRequest {
query: query.clone(),
limit: None,
language: Some(crate::i18n_helper::cli_lang().to_string()),
});
let response = super::send_request(request).await?;
match response {
Response::SearchDocs(resp) => {
println!("{}", format_results(&resp.results));
Ok(())
}
Response::Error(e) => bail!("{}", e.error),
_ => bail!("unexpected response"),
}
}
The interesting part lives in the daemon. Coasts uses a hybrid ranker:
- BM25 for lexical matching
- semantic-neighbor boosting
- per-locale tokenization
- locale fallback to English if a localized index is missing
These constants tell most of the story:
const SEARCH_DEFAULT_LIMIT: usize = 10;
const SEARCH_MAX_LIMIT: usize = 50;
const BM25_K1: f64 = 1.5;
const BM25_B: f64 = 0.75;
const SEMANTIC_BOOST_WEIGHT: f64 = 0.3;
And the ranking flow is straightforward:
let mut sorted_by_bm25: Vec<(usize, f64)> = scores.iter().map(|(k, v)| (*k, *v)).collect();
sorted_by_bm25.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
sorted_by_bm25.truncate(20);
let mut boosted = scores.clone();
for (section_id, section_score) in sorted_by_bm25 {
let neighbors_key = section_id.to_string();
let Some(neighbors) = index.semantic_neighbors.get(&neighbors_key) else {
continue;
};
for n in neighbors {
let boost = section_score * n.score * SEMANTIC_BOOST_WEIGHT;
*boosted.entry(n.s).or_insert(0.0) += boost;
}
}
We landed on this because pure keyword search felt too literal, while pure embeddings felt a little too fuzzy for command-line and operational docs. The hybrid approach gave us something more useful in practice: exact matches still rank well, but nearby sections can surface when the wording does not line up perfectly.
How we build the embeddings
The search index build is just a Python script. It reads a generated docs manifest, chunks docs by heading, sends batches to the embeddings API, computes semantic neighbors, and writes two files per locale:
search-indexes/docs-search-index-<locale>.jsonembeddings/docs-embeddings-<locale>.json
The inputs are section-level chunks, not entire files:
def chunk_by_heading(file_path: str, markdown: str) -> List[Dict[str, Any]]:
lines = markdown.split("\n")
sections: List[Dict[str, Any]] = []
current_heading = ""
current_lines: List[str] = []
def flush() -> None:
content = "\n".join(current_lines).strip()
if content:
sections.append({
"filePath": file_path,
"heading": current_heading,
"content": content,
})
The embedding settings in Coasts are currently:
EMBEDDING_MODEL = "text-embedding-3-large"
EMBEDDING_BATCH_SIZE = 100
SEMANTIC_TOP_K = 5
When generating vectors, each section gets truncated before embedding:
texts = [f"{s['heading']}\n\n{s['content']}"[:8000] for s in sections]
embedding_vectors = get_embeddings(texts, api_key)
You effectively have a 2023 YC startup baked into your CLI.
The implementation here mostly lives in scripts/generate-search-index.py with the runtime ranking in coast-daemon/src/handlers/docs.rs.
The actual artifact sizes
One thing I like about this pattern is that the size tradeoff is easy to reason about.
In the current Coasts tree:
embeddings/docs-embeddings-en.jsonis about 15 MB- the translated embeddings files are about 16 MB each
search-indexes/docs-search-index-en.jsonis about 1.2 MB- the largest search index in this repo right now is Japanese at about 3.3 MB
- the
coastbinary is about 30 MB
That is bigger than a simple keyword index, obviously. But it is still small enough to be practical for a local-first developer tool, especially when you get semantic search and offline packaged behavior in return.
The other nice part is that the search indexes are not magic blobs. They are plain JSON, easy to inspect, easy to regenerate, and easy to diff.
This also makes SKILL.md easy
Once you have docs, docs --path, and search-docs, the skill file stops being very complicated. It does not need to restate the whole manual. It just needs to explain what the CLI is for, what a few important commands are, and how to find the docs when the agent gets stuck.
Say you had a CLI called my-cli. A decent SKILL.md would look more like this:
# My CLI
Use `my-cli` for the core workflow of the tool.
Start with normal commands like:
```bash
my-cli --help
my-cli status
my-cli run
my-cli logs
```
If you need documentation, do this:
```bash
my-cli docs
my-cli docs --path path/to/doc.md
my-cli search-docs "how do I install x?"
```
That is it. The agent knows what the tool is for and it has a reliable way to find the rest without you stuffing the entire CLI reference into the skill.