---
title: "Find a Capability by Intent"
description: "Turn a sentence into a tool name. Three different searches answer that question over three different corpora, and only one of them costs a single call."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="@codespar/sdk" accent /><ServerChip name="curl" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>LLM optional</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="none" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>one connected provider for path B</span></>) },
]} />

Start from a phrase like "send a receipt over WhatsApp", end with the name of something you can actually call, and know which of the three searches answers the question you are really asking.

<EventPipeline
  title="DISCOVERY BY INTENT"
  subtitle="One phrase → one call → a branch that never retries"
  nodes={[
    { tag: "1 · ASK", name: "POST /v1/tools/search", meta: "intent 1..2000 · limit 1..5 · scope servers:read", tone: "event" },
    { tag: "2 · READ", name: "hits[] and source", meta: "always HTTP 200, even when the model never ran", tone: "validation" },
    { tag: "3 · BRANCH", name: "empty hits stop the loop", meta: "GET /meta-tools.json · 15 entries · no credential", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "GET /v1/providers", description: "take a real server id" },
    { tool: "POST /v1/sessions", description: "servers has minItems 1" },
    { tool: "POST /v1/sessions/{id}/execute", description: "tool codespar_discover" },
    { tool: "DELETE /v1/sessions/{id}", description: "idempotent, keeps the first closed_at" },
  ]}
  note="path A is one call over the 15 meta-tools · path B, in the strip below, is four calls over the raw provider catalog"
/>

## Prerequisites

```bash
npm install @codespar/sdk
```

The REST client used here, `cs.api`, ships in `@codespar/sdk` 0.16.0. Nothing has to be connected for path A or path C to answer. Path B needs a project with at least one provider connected, because it ranks provider rails and the connection state feeds the rank.

| What you need | Why |
|---|---|
| `servers:read` on the key | Gate on `POST /v1/tools/search` and on `POST /v1/meta-tools/discover`. |
| `sessions:create` and `tools:execute` | Path B opens a session and executes a meta-tool inside it. |
| One real server id | `POST /v1/sessions` refuses an empty `servers` array. Ids come from `GET /v1/providers`. |

## Three searches, three corpora

The names do not warn you. These are not one search with three transports. They rank different things and they answer different questions.

| Call | What it ranks | Session |
|---|---|---|
| `POST /v1/tools/search` | The 15 published `codespar_*` meta-tools, name and description only | None. One call. |
| `codespar_discover` via `POST /v1/sessions/{id}/execute` | Every row of `mcp_tools`, the raw provider rails behind the meta-tools | Yes. Four calls. |
| `POST /v1/meta-tools/discover` | Nothing. It picks a provider for an operation you already chose | None. One call. |

Pick by the corpus, not by the name. Asking `POST /v1/tools/search` for a specific provider rail returns a meta-tool, because a provider rail is not in the list it reads. Asking `codespar_discover` a question about `wallet` or `ledger` can return nothing at all, for reasons in the section on reading its result.

## Path A: one phrase, one call

The cheapest path. No session, no state, no cleanup.

```bash
curl -sS -X POST https://api.codespar.dev/v1/tools/search \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"intent":"charge a customer in BRL with pix","limit":3}'
```

```json
{
  "intent": "charge a customer in BRL with pix",
  "hits": [
    {
      "tool_name": "codespar_charge",
      "confidence": "high",
      "rationale": "collects money from a customer on a local rail"
    }
  ],
  "elapsed_ms": 412,
  "source": "llm"
}
```

`intent` is required and runs 1 to 2000 characters. `limit` runs 1 to 5, and any larger value is a `400`, the copy-paste value `1000` included. `tool_name` is always one of the 15 published names: the classifier is handed only the name and description of each meta-tool, and anything it invents outside that list is filtered out before the response is built.

There is no named SDK method for this route. In TypeScript it goes through the generic REST client:

```typescript title="search.ts"
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

const res = await cs.api.post("/v1/tools/search", {
  body: { intent: "charge a customer in BRL with pix", limit: 3 },
});

for (const hit of res.hits) {
  console.log(hit.tool_name, hit.confidence, hit.rationale);
}
```

From the terminal, the same route is a derived command:

```bash
codespar catalog search -i '{"intent":"charge a customer in BRL with pix","limit":3}'
```

## What `source` tells you, and what it hides

`source` is `llm` or `fallback`, and it is the only decision this route asks you to make.

The handler reads `ANTHROPIC_API_KEY` from the environment. If it is absent, the model is never called: the response is the lexical heuristic, marked `fallback`. If it is present, the call goes to `claude-haiku-4-5-20251001` with `max_tokens` 512 and `temperature` 0. Any throw on that path, a rate limit, a network error, a malformed body, lands in a catch, logs a warning and returns the same heuristic, also marked `fallback`.

All three outcomes are HTTP 200. This route has no 5xx from the classifier. It is the one point in the contract where a refusal does not arrive as a refusal.

<Callout type="warn">
**`hits: []` with `source: "llm"` is also a failure mode, and nothing in the response separates it from a real answer.** The parser wraps `JSON.parse` in a try and returns an empty array when the model text does not parse, and the response is still stamped `llm`. Garbage from the model and an honest "nothing matches" produce identical bytes. `source` separates the model from the heuristic. It does not separate a good answer from a corrupted one.
</Callout>

Two more things about the degraded mode, both of which bite agents:

- The heuristic is lexical over English text. It splits the phrase, drops tokens under 3 characters and 22 English stopwords, then counts substring hits in the tool name and description, which are written in English. A Portuguese phrase like "emitir uma nota fiscal de servico" matches nothing and returns an empty list.
- The heuristic never emits `high`. It emits `medium` at a score of 2 or more and `low` below that. A caller that keeps only `high` throws away 100 percent of the degraded mode.

## Empty hits is not a retry signal

<Callout type="warn">
**Stop searching when `hits` comes back empty.** The two common causes, a missing model key and a phrase with no lexical overlap, are permanent until someone changes configuration or changes the phrase. An identical retry returns the identical empty list forever, and each turn spends a call. If you insist, change the phrase. Never repeat the same one.
</Callout>

The honest branch is to read the catalog instead. It is 15 entries, it needs no credential, it sets `Access-Control-Allow-Origin: *`, and it is cached for 5 minutes.

```typescript
if (res.hits.length === 0) {
  const catalog = await fetch("https://api.codespar.dev/meta-tools.json").then((r) => r.json());
  console.log(catalog.generated_from); // "tools/list"
  for (const tool of catalog.tools) {
    console.log(tool.name, tool.description);
  }
}
```

`GET /v1/meta-tools.json` returns the same bytes, for clients already pointed at the versioned prefix. Offline, `codespar tools meta` prints the published definitions straight out of `@codespar/types` without touching the network.

## Path B: search the raw provider catalog

`codespar_discover` ranks `mcp_tools`, which is the table of provider rails. It is reachable only inside a session, so a read question costs four HTTP calls.

```typescript title="discover.ts"
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

// 1. A real server id. GET /v1/servers is the deprecated alias of this route.
const catalog = await cs.api.get("/v1/providers");
console.log(catalog);

// 2. servers has minItems 1 and maxItems 20. An empty array is a 400.
const session = await cs.create("user_123", { servers: ["asaas"] });

try {
  // 3. Sugar over session.execute("codespar_discover", { use_case, ... }).
  const result = await session.discover("send a whatsapp message to a customer", {
    limit: 5,
    country: "BR",
  });

  console.log(result.search_strategy);
  console.log(result.recommended);
  console.log(result.related);
  console.log(result.next_steps);

  // meta_tools is returned at runtime and absent from the published type.
  const native = (result as unknown as { meta_tools?: unknown[] }).meta_tools;
  console.log(native);
} finally {
  // 4. Idempotent. Closing a closed session returns the original closed_at.
  await session.close();
}
```

The raw call underneath, if you want the untouched envelope:

```bash
curl -sS -X POST https://api.codespar.dev/v1/sessions/$SESSION_ID/execute \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tool":"codespar_discover","input":{"use_case":"send a whatsapp message","limit":5}}'
```

### The order things happen in

The route validates before it does anything else, and the checks are ordered. Knowing the order tells you what already happened when an error arrives.

1. The body is parsed. A failure is `400 invalid_body`.
2. The session status is checked. A closed session is `409 session_not_active`.
3. The org quota is checked. Over the line is `403 quota_exceeded`.
4. The tool is resolved: `codespar_list_tools` first, then the meta-tool table, then the registered catalog. Nothing matching is HTTP 200 with `success: false` and `Tool not registered`.
5. Arguments are coerced, then the per-action scope gate runs. `codespar_discover` maps to `tools:execute`, and the gate only refuses when the deployment sets `META_TOOL_ACTION_SCOPES_ENFORCE=true`, which is off by default.

Inside the tool, `use_case` is read first, with `query` accepted as a compatibility alias. If both are missing or empty, it throws `invalid_args` before it touches the database. Nothing is written, nothing is charged, and the HTTP status is still 200.

`codespar_discover` is also diverted before the tool router. No eligibility check, no failover, no telemetry, no `idempotency_key`. It is a pure read.

### How it actually searches

The semantic pass asks OpenAI for an embedding of the query, with a 5 second abort. It returns nothing when `OPENAI_API_KEY` is unset, when the response is not ok, and on any exception or timeout. With a vector, the query selects over `mcp_tools` rows that have an embedding and orders by cosine distance. Zero rows there, or no vector at all, and it falls to trigram similarity at threshold 0.1 over `tool_name` and `description`, plus the provider's own `name` and `description` weighted at 0.7. That weighted pair is why "send whatsapp message" finds the z-api rails. Zero rows in both passes sets `search_strategy` to `empty`.

Ranking adds a connection bonus, 0.02 on the cosine path and 0.05 on the trigram path, for rails that are connected or need no connection. In `environment=test` a disconnected rail behind non self-serve auth, a certificate, an HMAC pair, a JWT or a two-header scheme, takes a penalty of 1000, which buries it.

## Reading the discover result without falling in

<Callout type="warn">
**`recommended: null` does not mean "found nothing".** It is forced to null in two success cases. When the in-memory keyword table matches a native meta-tool, the whole provider catalog is pushed down into `related` on purpose and `next_steps[0]` tells you to call the native meta-tool directly. It is also null when the top of the ranking is blocked in the test environment. An agent branching on `if (!recommended) give_up()` gives up exactly when the answer is better than a raw rail.
</Callout>

Four more traps in this response.

- **`meta_tools` is not in the published type.** The served description of the tool says to prefer a native meta-tool when one is returned, and the backend returns them under `meta_tools`. `DiscoverResult` in `@codespar/types` 0.11.0 declares `use_case`, `search_strategy`, `recommended`, `related` and `next_steps`, and nothing else. TypeScript callers need a cast to see the field. The information survives in typed code only through the text of `next_steps[0]`.
- **`meta_tools` knows 10 of the 15.** The hint table covers shop, manage_connections, pay, charge, invoice, ship, notify, kyc, crypto_pay and checkout. `codespar_discover` is excluded deliberately, since a tool never recommends itself. `wallet`, `ledger`, `issue` and `get_started` are absent with no note. An intent about balances, statements, the ledger or card issuing never surfaces through this field, and when the `mcp_tools` catalog also fails to match, the answer is empty for a capability that exists.
- **What it recommends is often not callable from where you asked.** `recommended` and `related` are catalog rows, shaped as `server_id.tool_name`. The [hosted agent transport](/docs/concepts/meta-tools) accepts only the 15 published meta-tool names plus `codespar_list_tools`, so an answer like `asaas.create_transfer` names a tool that transport refuses. Raw catalog tools run only through `POST /v1/sessions/{id}/execute`.
- **This search sees your tenant. Path A does not.** `POST /v1/tools/search` never reads the auth context: no org, no project, no environment, no connection state. Its answer is byte identical for every caller. `codespar_discover` checks connected accounts per org and project, adds the connection bonus, and hides generated tools belonging to another project. Treating them as the same search with a different transport leads to recommending a rail the tenant cannot invoke.

## Path C: pick the provider, not the tool

`POST /v1/meta-tools/discover` is not a tool search. It takes an operation you have already chosen and picks the rail that will carry it.

```bash
curl -sS -X POST https://api.codespar.dev/v1/meta-tools/discover \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"intent":{"operation":"charge","currency":"BRL","country":"BR","amount_minor":4990}}'
```

`operation` is one of `charge`, `refund`, `payout`, `issue_invoice`, `send_notification` and `ship`. With `currency` and `country` it is required. `amount_minor`, `urgency`, `recipient_kind` and `metadata` are optional. In that structured form the lookup is deterministic and free.

The alternative body is `{ "description": "..." }`, 1 to 2000 characters, which needs the managed-tier extractor. Without it the answer is `501`. There is no named SDK method for this route in TypeScript or Python, and no CLI command. The generic REST client reaches it as `cs.api.post("/v1/meta-tools/discover", { body })`.

Order inside: the intent is resolved, structured or extracted, then mapped to a meta-tool and a rail, then routed over org, meta-tool, rail, currency and country. An intent that maps to nothing is `400 intent_unmappable`, and `operation: "ship"` lands there every time.

<Callout type="warn">
**A `424` fires one write from this read endpoint, then refuses to do it again for 30 seconds.** On `eligibility_empty` or `direction_blocked` the handler stamps its cooldown before the await, seeds the shared sandbox for the project, invalidates the eligibility memo and re-routes exactly once. The stamp lives in a per-process map keyed by org and project, so each replica of a multi-replica deployment carries its own cooldown. Polling on top of a 424 accelerates nothing: a second call inside the window skips the repair and answers from what is already there. The suppression is logged, not silent.
</Callout>

Two fields on the response invite misreading. `provider_id` is transparency, not a reservation: the router runs again at execution time and may pick a different rail, which the served description says outright. An empty `alternatives` array is not a failure, it means exactly one route was eligible. And `args_template` never carries `action`, which every money meta-tool requires. It fills `currency`, `metadata`, and `amount` only for `codespar_pay`, so for any other meta-tool the `amount_minor` you sent is dropped in silence.

## Refusals, and which retry fixes which

| Call | Refusal | Does a retry fix it |
|---|---|---|
| `POST /v1/tools/search` | `400 invalid_body` in the standard envelope, with `request_id` echoing `X-Request-Id` | No. `intent` is missing, empty or over 2000 characters, or `limit` is outside 1 to 5. |
| `POST /v1/tools/search` | `401` with the raw body `{"error":"unauthorized"}`, not the standard envelope | No, not without a valid credential. |
| `POST /v1/tools/search` | `403 forbidden`, message `API key does not have the 'servers:read' scope.` | No. Widen or swap the key. |
| `POST /v1/tools/search` | No 5xx exists. A missing model key, a rate limit, a network error and malformed model output all return 200 | Nothing to retry. Read `source` and `hits`. |
| `POST /v1/sessions` | `400 invalid_body` with `issues`, or `400 unknown_servers` with `unknown` | No. An empty `servers` array fails the first: the minimum is 1. |
| `POST /v1/sessions/{id}/execute` | `200` with `success: false` and `data.code` of `invalid_args` when `use_case` is empty | No. A client reading only the status reads this as success. |
| `POST /v1/sessions/{id}/execute` | `400 invalid_body` or `400 invalid_query` with `issues`, in a raw envelope | No. |
| `POST /v1/sessions/{id}/execute` | `409 session_not_active`, carrying the real status | Only after opening a new session. |
| `POST /v1/sessions/{id}/execute` | `403` in two disjoint bodies. Policy carries `reason`, `ruleType` and `ruleId` and has no `error` key at all, plus `approval_id` and `expires_at` when the refusal opened an approval. Quota carries `error` of `quota_exceeded` with `plan`, `limit` and `used` | No. Branch on whether `error` is present. Quota is off by default, since tool calls are free on the rate card. |
| `POST /v1/sessions/{id}/execute` | `404` for a session in another project, indistinguishable from one that never existed | No. |
| `POST /v1/sessions/{id}/execute` | `422` with `mocks_exhausted` or `tool_not_mocked`, from the catalog branch only. A meta-tool never answers 422 | No, not without more mocks. |
| `POST /v1/sessions/{id}/execute` | `503` in two bodies at the same status: `error` of `policy_engine_error`, and `code` of `mocks_engine_error` | Possibly. Both are our side. |
| `POST /v1/meta-tools/discover` | `400 invalid_body` when neither body shape matched, `400 intent_unmappable` when the operation maps to no meta-tool | No. `operation: "ship"` always lands on the second. |
| `POST /v1/meta-tools/discover` | `424 eligibility_empty` and `424 direction_blocked`, which ask for opposite actions | No. The first wants a provider connected. The second has candidates and they all move money the wrong way, so connecting another provider does not help. Branch on `error`, treat `detail` as screen text. |
| `POST /v1/meta-tools/discover` | `501 nl_unavailable` after sending `description` to a deployment with no extractor | Never in that shape. A structured `intent` always works. |
| `POST /v1/meta-tools/discover` | `502 nl_extraction_failed` after the extractor ran and produced nothing usable | Rephrasing can work. A structured `intent` always works. |
| The [agent transport](/docs/concepts/meta-tools), `POST /mcp` and `POST /v1/sessions/{id}/mcp` | A tool result with `isError: true` and `tool_not_published`. Not an HTTP error, and no row in `session_tool_calls` | Never. Raw catalog tools run only through `POST /v1/sessions/{id}/execute`. |

One SDK detail that flattens this table into a single shape: `session.execute` does not throw on a non-2xx. It synthesizes `success: false` with `error` set to the status and the body joined into one string. A policy 403 and an unregistered tool arrive in the same object, and only the prefix of that string separates them. `session.discover` throws on top of that, with the message `discover failed:` and the error text.

## What this does not do

- **It does not run path C from the CLI.** The `meta-tools` group is excluded from the derived command surface, with the stated reason that `codespar discover` already calls it. That reason is wrong: `codespar discover` calls `session.discover`, which executes the `codespar_discover` meta-tool inside a session. The two surfaces share a word and nothing else. From the terminal, path C is curl only.
- **It does not give path A a named SDK method.** TypeScript reaches it through `cs.api.post` and the generated operations table. The Python package has no reference to the route at all.
- **It does not give path C a named SDK method either.** `session.discover()` is not a wrapper for it.
- **It does not declare `meta_tools` on `DiscoverResult`.** The runtime returns the field, `@codespar/types` 0.11.0 does not declare it.
- **It does not publish the [agent transport](/docs/concepts/meta-tools) in the OpenAPI document.** None of the 276 paths in the served document ends in `/mcp`. The routes exist and the scope map knows them, but a client working only from the specification cannot learn that the transport exists.
- **It does not expose `codespar_discover` over a single call.** There is no HTTP route that returns its result without opening a session. The one-call route, `POST /v1/tools/search`, answers over a different corpus.
- **It does not confirm that a provider exists.** `GET /v1/servers/{id}/tools` answers 200 with an empty list for an id that names no provider. The response cannot tell a real provider with no tools from one that was never there.
- **It does not reach `codespar_cart`.** The tool has a dispatch branch and a scope mapping, and no entry in the published catalog, so the live `meta-tools.json` lists 15 names without it. Executing it returns 200 with `Tool not registered`, and none of the three searches can return it.
- **It does not work from `codespar discover` against the live API.** In `@codespar/cli` 0.11.2, both `codespar discover` and `codespar tool <name>` open the session with an empty `servers` array. The SDK passes that through, and `POST /v1/sessions` refuses it with `400 invalid_body`, which the CLI surfaces as an internal error with a stack trace and exit code 2. Use `codespar catalog search` for path A, or the SDK for path B.

<NextStepsGrid items={[
  { label: "API", title: "Tools", description: "Every field on POST /v1/tools/search, with the request and response schemas as the document declares them.", href: "/docs/api/reference/tools" },
  { label: "API", title: "Meta-Tools", description: "The routing call of path C: intent shapes, the 424 pair, and the fields on the chosen route.", href: "/docs/api/reference/meta-tools" },
  { label: "CONCEPT", title: "Tool Router", description: "How eligibility and direction decide which rail carries an operation, which is what path C surfaces.", href: "/docs/concepts/tool-router" },
]} />
