Find a Capability by Intent
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.
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.
Prerequisites
npm install @codespar/sdkThe 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.
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}'{
"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:
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:
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.
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.
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 emitsmediumat a score of 2 or more andlowbelow that. A caller that keeps onlyhighthrows away 100 percent of the degraded mode.
Empty hits is not a retry signal
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.
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.
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.
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:
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.
- The body is parsed. A failure is
400 invalid_body. - The session status is checked. A closed session is
409 session_not_active. - The org quota is checked. Over the line is
403 quota_exceeded. - The tool is resolved:
codespar_list_toolsfirst, then the meta-tool table, then the registered catalog. Nothing matching is HTTP 200 withsuccess: falseandTool not registered. - Arguments are coerced, then the per-action scope gate runs.
codespar_discovermaps totools:execute, and the gate only refuses when the deployment setsMETA_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
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.
Four more traps in this response.
meta_toolsis 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 undermeta_tools.DiscoverResultin@codespar/types0.11.0 declaresuse_case,search_strategy,recommended,relatedandnext_steps, and nothing else. TypeScript callers need a cast to see the field. The information survives in typed code only through the text ofnext_steps[0].meta_toolsknows 10 of the 15. The hint table covers shop, manage_connections, pay, charge, invoice, ship, notify, kyc, crypto_pay and checkout.codespar_discoveris excluded deliberately, since a tool never recommends itself.wallet,ledger,issueandget_startedare absent with no note. An intent about balances, statements, the ledger or card issuing never surfaces through this field, and when themcp_toolscatalog also fails to match, the answer is empty for a capability that exists.- What it recommends is often not callable from where you asked.
recommendedandrelatedare catalog rows, shaped asserver_id.tool_name. The hosted agent transport accepts only the 15 published meta-tool names pluscodespar_list_tools, so an answer likeasaas.create_transfernames a tool that transport refuses. Raw catalog tools run only throughPOST /v1/sessions/{id}/execute. - This search sees your tenant. Path A does not.
POST /v1/tools/searchnever reads the auth context: no org, no project, no environment, no connection state. Its answer is byte identical for every caller.codespar_discoverchecks 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.
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.
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.
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, 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-toolsgroup is excluded from the derived command surface, with the stated reason thatcodespar discoveralready calls it. That reason is wrong:codespar discovercallssession.discover, which executes thecodespar_discovermeta-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.postand 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_toolsonDiscoverResult. The runtime returns the field,@codespar/types0.11.0 does not declare it. - It does not publish the agent transport 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_discoverover 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}/toolsanswers 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 livemeta-tools.jsonlists 15 names without it. Executing it returns 200 withTool not registered, and none of the three searches can return it. - It does not work from
codespar discoveragainst the live API. In@codespar/cli0.11.2, bothcodespar discoverandcodespar tool <name>open the session with an emptyserversarray. The SDK passes that through, andPOST /v1/sessionsrefuses it with400 invalid_body, which the CLI surfaces as an internal error with a stack trace and exit code 2. Usecodespar catalog searchfor path A, or the SDK for path B.
KYC Onboarding
From zero to a provisioned consumer account: open the application, poll the status, and read what exists after approval. The order each handler runs in, the two shapes the POST answers in, and which refusal a retry actually fixes.
Open Finance Consent
From the consent at the bank to the ingested statement: initiate, finalise, refresh, revoke. The order each handler runs in, what already happened when a refusal arrives, and which refusal a retry actually fixes.