# Recommend

Ask which tool works for a task and get them ranked by score, then price.

`POST /v1/recommend` tells your agent which tool to use for a task. You send one of the task types, or the task in plain language. You get back the tools for that task, ranked by score, each with the price of one passing result and the check that result must pass.

The call is free: it uses no credits. It does count against your plan's daily lookup quota. Nothing is run or bought here. To buy a result, send the `tool_id` you pick to [execute](/docs/execute).

It needs an agent key, `sk_test_…` or `sk_live_…`, in the `Authorization` header (see [Authentication](/docs/authentication)). Test keys see mock tools. Live keys see the real catalog. Without a key you can still read the catalog and scores, unranked, through [Public data](/docs/public-data).

> Arettic is pre-launch. Keys go to design partners; everyone else can [join the waitlist](/waitlist). The `@arettic/sdk` (npm), `arettic` (PyPI) and `@arettic/mcp` packages used on this page are published at launch.

## Call it

**curl**

```bash
curl -s https://api.arettic.com/v1/recommend \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "task_type": "find_email",
    "region": "US",
    "constraints": { "max_price": 50, "min_score": 40 },
    "sort": "score",
    "limit": 5
  }'
```

This asks for `find_email` tools that serve the US, cost at most 50 credits per passing result and score at least 40, five at most. With a test key the only match is the mock finder:

**Response (example)**

```json
{
  "task_type": "find_email",
  "region": "US",
  "sort": "score",
  "test_mode": true,
  "formula_version": "v1",
  "ranking": "score, then price when scores are within 2 points; purchasability never changes rank",
  "options": [
    {
      "tool_id": "mock-find-email",
      "name": "Mock Email Finder",
      "provider": "Arettic Mock Provider",
      "score": 56.53,
      "score_week": "2026-09-28",
      "score_inputs": { "A": 0.4902, "S": 0.5958, "P": 0.4902, "R": 0.7225, "L": 1, "D": 0 },
      "sample_size": 10,
      "price_per_success": { "credits": "38", "usd": "0.038" },
      "success_rate": 0.5958,
      "p50_latency_ms": 5,
      "pass_rule": "find_email@v1",
      "purchasable": true,
      "regions": ["GLOBAL", "US"]
    }
  ]
}
```

`options[0].tool_id` is what you send to execute. Check `purchasable` first: ranking ignores it, so the top option can be a tool that is listed for information only (see [Info-only tools](#info-only)).

## Request

`POST https://api.arettic.com/v1/recommend` with a JSON body. Headers: `Authorization: Bearer <agent key>` and `Content-Type: application/json`. Every field is optional, except that you must send `task_type` or `task`.

Request fields

| Field | Type | Default | What it does |
|---|---|---|---|
| task_type | string | none | One of the task types, listed below. Required unless you send `task`. When both are sent, `task_type` is used and `task` is ignored. |
| task | string | none | The task in plain language, up to 500 characters (longer text is cut). Arettic maps it to a task type and says which one it chose. Counts as a free-text lookup as well as a score lookup. |
| region | string | `GLOBAL` | Where the results are needed, for example `US`. Upper-cased. An empty string means `GLOBAL`. |
| constraints.max_price | number | none | Price cap in credits (1 credit = $0.001). Tools whose price per success is above it are left out. Tools with no price stay in. |
| constraints.min_score | number | none | Lowest score to include, on the 0 to 100 scale. Tools with no score yet are left out whenever you set it, even at 0. |
| sort | string | `score` | `score`, `price`, `value` or `latency` (see [How tools are ranked](#ranking)). Anything else falls back to `score`. |
| limit | number | 10 | How many options to return, 1 to 50. Decimals are rounded down, 0 becomes 1, and more than 50 becomes 50. |

Numbers are checked strictly. `constraints.max_price`, `constraints.min_score` and `limit` must be non-negative numbers, or the call answers [invalid_input](/docs/errors#invalid_input) with a message that names the field, for example "`limit` must be a non-negative number". A body that isn't a JSON object answers `invalid_input` too. An unknown `sort` is not an error: it falls back to `score`.

The task types: `find_email`, `verify_email`, `enrich_company`, `enrich_person`, `web_search`, `extract_url`. Send `task_type` when you know it. It skips the mapping step and the free-text quota.

## Response

Response fields

| Field | Meaning |
|---|---|
| task_type | The task type the options are for. |
| mapped_from_task | Only when you sent `task`: how the text was mapped, as `from`, `confidence` and `alternatives`. See [Free-text tasks](#free-text). |
| region | The region used, upper-cased. `GLOBAL` when you sent none. |
| sort | The sort that was applied. |
| test_mode | `true` for a test key. Test keys see mock tools only. |
| formula_version | The version of the public score formula behind every `score`. Today `v1`. |
| ranking | The ranking rule as a sentence, so an agent reading the JSON knows how the list was ordered. |
| options | The ranked tools, at most `limit` of them. Empty when no tool matches. |

### Each option

Option fields

| Field | Type | Meaning |
|---|---|---|
| tool_id | string | The id you send to [execute](/docs/execute) as `tool_id`. |
| name | string | The tool's name. |
| provider | string | The provider's name. |
| score | number or null | The tool's score, 0 to 100, from the public formula. `null` when the tool has no score yet. |
| score_week | string or null | The Monday (UTC) of the week the score was computed for, as `YYYY-MM-DD`. |
| score_inputs | object or null | Every input to the score: `A`, `S`, `P`, `R`, `L` and `D`, each between 0 and 1. The table below says what they are. |
| sample_size | number | Data points behind the score: benchmark cases plus live calls in the last 28 days. `0` without a score. |
| price_per_success | object or null | The pay-as-you-go price of one passing result, as `{ credits, usd }`, both strings (1 credit = $0.001). `max_price` is compared with this number. Pro and Max orgs pay less: `GET /v1/tools/{id}` shows `prices_by_plan`, and execute charges your plan's price. `null` when the tool has no price. |
| success_rate | number or null | The same as `score_inputs.S`: the share of calls that passed the check. |
| p50_latency_ms | number or null | Median latency in the tool's latest benchmark run, in milliseconds. `null` when it has none. |
| pass_rule | string | The check a result must pass before you are charged, as `task_type@version`, for example `find_email@v1`. See [Pass rules](/docs/pass-rules). |
| purchasable | boolean | Whether execute can buy it through Arettic. Never changes the rank. |
| regions | string[] | The regions the tool serves, for example `["GLOBAL", "US"]`. |

Score inputs

| Input | Meaning |
|---|---|
| A | Accuracy: the share of benchmark cases where a correct result was delivered. |
| S | Pass rate: benchmark and live calls in the last 28 days, weighted by volume. |
| P | Audit precision: the share of audited passes confirmed correct. Uses `A` until 50 audits exist. |
| R | Reliability: 1 minus the tool's error and timeout rate across its benchmark cases and live calls in the last 28 days. |
| L | Speed: the task's median latency divided by this tool's latency, capped at 1. |
| D | Upheld disputes divided by passed results. This one is a penalty. |

The formula, its weights and its rules are on [How scores work](/docs/scores) and at `GET https://api.arettic.com/v1/formula`.

## How tools are ranked

The default order is by score, highest first. Two scores within 2 points of each other count as a tie: the cheaper tool comes first, and at the same price the higher score. Tools with no score yet come after every scored tool, cheapest first.

Whether a tool can be bought through Arettic never changes its rank. It is a field, `purchasable`, not a factor. In the API's own tests, a tool one point below the top scorer but cheaper ranks first, and a tool 30 points below ranks last even though it is sold here.

Constraints are applied before sorting and `limit` after it. So `sort: "price"` with `limit: 1` gives you the cheapest tool that meets your constraints.

Sorts

| Sort | Order |
|---|---|
| score | The default. Highest score first, with the tie rule above. Tools with no score come last, cheapest first. |
| price | Cheapest price per success first. Tools with no price come last. Equal prices keep the `score` order. |
| value | Highest score divided by price in credits first. Tools with no score or no price come last. Equal values keep the `score` order. |
| latency | Lowest `p50_latency_ms` first. Tools with no latency come last. Equal latencies keep the `score` order. |

## Info-only tools

A tool is `purchasable` when it is curated for sale here and has a price. Every other listed tool is there for information only: you see its score and where it ranks, but execute refuses it with [not_purchasable](/docs/errors#not_purchasable) (HTTP 409). Its `price_per_success` may be `null`.

So take the first option with `purchasable: true`, not `options[0]`. An info-only tool is still worth reading: it shows how the tool you buy compares with the rest of the market.

## Regions

`region` says where the results are needed. It is upper-cased, so `us` and `US` are the same. Leave it out for `GLOBAL`.

A tool is listed when its `regions` include your region or `GLOBAL`. Its score is the region's own score when one exists, otherwise its `GLOBAL` score; `score_week` and `score_inputs` come from whichever was used. The response repeats the region in `region`.

A region with no tools of its own, or a value that isn't a region at all, gives you the tools that serve `GLOBAL` with their `GLOBAL` scores.

## Free-text tasks

When you send `task` instead of `task_type`, Arettic maps the text to a task type with a keyword classifier. It is deterministic and calls no model: the same text always gives the same type. It costs one free-text lookup from your daily quota, nothing else.

How it works: the text is lower-cased, extra spaces are collapsed, and anything after 500 characters is dropped. Each task type has a few keyword patterns with weights, for example "find … email" for `find_email`, "bounce" for `verify_email`, "search the web" for `web_search`. The type with the highest total wins, as long as that total is at least 2. Otherwise the call answers [unknown_task_type](/docs/errors#unknown_task_type) (HTTP 400): "Couldn't map that task to a task type. Send task_type as one of: …".

The response always says what it chose, in `mapped_from_task`:

mapped_from_task

| Field | Meaning |
|---|---|
| from | Your text, trimmed, up to 500 characters. |
| confidence | The winner's total divided by the winner's plus the runner-up's, rounded to 2 decimals. `1` when only one type matched. |
| alternatives | Up to two other task types that also matched, best first. Empty when nothing else matched. |

If the type is wrong, or `confidence` is low, send `task_type` explicitly. For example, this text matches two types and the runner-up may be the one you meant:

**curl**

```bash
curl -s https://api.arettic.com/v1/recommend \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "task": "find the email and job title of the CTO at acme.com" }'
```

**Response (example)**

```json
{
  "task_type": "enrich_person",
  "mapped_from_task": {
    "from": "find the email and job title of the CTO at acme.com",
    "confidence": 0.57,
    "alternatives": ["find_email"]
  },
  "region": "GLOBAL",
  "sort": "score",
  "test_mode": true,
  "formula_version": "v1",
  "ranking": "score, then price when scores are within 2 points; purchasability never changes rank",
  "options": [
    {
      "tool_id": "mock-enrich-person",
      "name": "Mock Person Enrichment",
      "provider": "Arettic Mock Provider",
      "score": 62.87,
      "score_week": "2026-09-28",
      "score_inputs": { "A": 0.5958, "S": 0.5958, "P": 0.5958, "R": 0.7225, "L": 1, "D": 0 },
      "sample_size": 10,
      "price_per_success": { "credits": "45", "usd": "0.045" },
      "success_rate": 0.5958,
      "p50_latency_ms": 5,
      "pass_rule": "enrich_person@v1",
      "purchasable": true,
      "regions": ["GLOBAL", "US"]
    }
  ]
}
```

Phrases from the API's own tests and where they map:

Free-text examples

| Task | Maps to |
|---|---|
| find the email of the marketing head at acme | `find_email` |
| check if these emails will bounce | `verify_email` |
| company size and industry for 100 US SaaS firms | `enrich_company` |
| get the job title and linkedin of the CMO | `enrich_person` |
| search the web for articles about sales tax compliance tools | `web_search` |
| scrape the text from https://acme.com/about | `extract_url` |
| make me a sandwich | Nothing: `unknown_task_type` |

## Daily quotas

Recommend is free, but each org has a daily number of lookups that depends on its plan. All the org's agents share it, and it resets at 00:00 UTC. Every call that reaches the catalog counts one score lookup, whether or not any tool matches. A call with `task` also counts one free-text lookup. That one is taken before the text is mapped, so a task that can't be mapped still counts, though it takes no score lookup.

Daily lookup quotas by plan

| Plan | Name | Score lookups a day | Free-text lookups a day |
|---|---|---|---|
| payg | Pay as you go | 1,000 | 100 |
| team | Pro | 10,000 | 500 |
| enterprise | Max | No fixed limit | No fixed limit |

When a quota is used up, the call answers HTTP 429 with the code [plan_limit](/docs/errors#plan_limit). The message says which limit it was. `retryable` is `false`, and the SDKs never retry recommend, so wait for the reset, or send `task_type` instead of `task` if it was the free-text quota that ran out.

**Response (example)**

```json
{
  "error": {
    "code": "plan_limit",
    "message": "Daily limit of 1000 score lookups reached for your plan. It resets at 00:00 UTC.",
    "doc_url": "https://arettic.com/docs/errors#plan_limit",
    "retryable": false
  }
}
```

The per-agent rate limit applies on top of the quota: 600 requests a minute for each agent key, reported in the `RateLimit-*` headers. See [Rate limits and quotas](/docs/rate-limits).

## Test keys and mock tools

With a test key (`sk_test_…`) the catalog is the mock provider: one tool per task type, ids starting with `mock-`, serving `GLOBAL` and `US`. They are benchmarked and scored like real tools, so constraints and sorts behave the same way. The response has `test_mode: true`. A live key never sees a mock tool, and a test key never sees a real one.

Mock tools

| Task type | Tool id | Name | Price per success (credits) |
|---|---|---|---|
| find_email | `mock-find-email` | Mock Email Finder | 38 |
| verify_email | `mock-verify-email` | Mock Email Verifier | 6 |
| enrich_company | `mock-enrich-company` | Mock Company Enrichment | 30 |
| enrich_person | `mock-enrich-person` | Mock Person Enrichment | 45 |
| web_search | `mock-web-search` | Mock Web Search | 8 |
| extract_url | `mock-extract-url` | Mock Page Extractor | 3 |

So `{"task_type": "find_email", "constraints": {"max_price": 10}}` returns no options with a test key: the mock finder costs 38 credits. With a live key you see only the tools that are switched on in the real catalog, so a task type can have no options until its providers are live. What the mock tools return from execute is on [Test mode](/docs/test-mode).

## SDK and MCP examples

Both SDKs read `ARETTIC_API_KEY` (and `ARETTIC_API_URL`) from the environment. Neither retries recommend, because it is a POST without an idempotency key. A refused call throws (TypeScript) or raises (Python) `AretticApiError` with the API's `code`. See [SDKs](/docs/sdks).

### TypeScript

**TypeScript**

```ts
import { Arettic } from "@arettic/sdk";

// Reads ARETTIC_API_KEY (and ARETTIC_API_URL) from the environment when you leave them out.
const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY });

const rec = await arettic.recommend({
  task_type: "find_email",
  region: "US",
  constraints: { max_price: 50, min_score: 40 },
  sort: "score",
  limit: 5,
});

// Ranking ignores whether a tool can be bought here, so take the first purchasable option.
const tool = rec.options.find((o) => o.purchasable);
if (!tool) throw new Error("No purchasable tool for " + rec.task_type + " in " + rec.region);

console.log(tool.tool_id, tool.score, tool.price_per_success?.credits, tool.pass_rule);
// mock-find-email 56.53 38 find_email@v1

// Or map plain language. The answer says which task type it chose.
const mapped = await arettic.recommend({ task: "find the work email of Emily Carter at example.com" });
console.log(mapped.task_type, mapped.mapped_from_task?.confidence); // find_email 1
```

### Python

**Python**

```python
from arettic import Arettic

client = Arettic()  # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set)

rec = client.recommend(
    task_type="find_email",
    region="US",
    constraints={"max_price": 50, "min_score": 40},
    sort="score",
    limit=5,
)

# Ranking ignores whether a tool can be bought here, so take the first purchasable option.
tool = next((o for o in rec["options"] if o["purchasable"]), None)
if tool is None:
    raise SystemExit(f"No purchasable tool for {rec['task_type']} in {rec['region']}")

print(tool["tool_id"], tool["score"], tool["price_per_success"]["credits"], tool["pass_rule"])
# mock-find-email 56.53 38 find_email@v1

# Or map plain language. The answer says which task type it chose.
mapped = client.recommend(task="find the work email of Emily Carter at example.com")
print(mapped["task_type"], mapped["mapped_from_task"]["confidence"])  # find_email 1
```

### MCP

The MCP server's `recommend` tool takes the same fields with two differences: `max_price` and `min_score` are top-level arguments (there is no `constraints` object), and there is no `limit`, so you get up to 10 options. `task` is limited to 500 characters, `region` to 10, and `min_score` to 100. The result is the same JSON as above, as one text content block. A refused call comes back as a result with `isError: true` and the API's error body. Setup is on [MCP server](/docs/mcp).

**MCP tool call**

```json
{
  "name": "recommend",
  "arguments": {
    "task_type": "find_email",
    "region": "US",
    "max_price": 50,
    "min_score": 40,
    "sort": "score"
  }
}
```

## Errors

Errors from recommend

| Code | HTTP | When |
|---|---|---|
| [invalid_input](/docs/errors#invalid_input) | HTTP 400 | The body isn't a JSON object, neither `task_type` nor `task` was sent, or `constraints.max_price`, `constraints.min_score` or `limit` isn't a non-negative number. The message names the field. |
| [unknown_task_type](/docs/errors#unknown_task_type) | HTTP 400 | `task_type` isn't one of the task types, or `task` couldn't be mapped to one. |
| [unauthenticated](/docs/errors#unauthenticated) | HTTP 401 | No agent key, or a revoked one. Only `sk_test_…` and `sk_live_…` keys work here; org keys (`ok_…`) don't. |
| [plan_limit](/docs/errors#plan_limit) | HTTP 429 | The org's daily score-lookup or free-text quota is used up. It resets at 00:00 UTC. |
| [rate_limited](/docs/errors#rate_limited) | HTTP 429 | Too many requests from this agent in a short time. Wait for the number of seconds in `Retry-After`. |

Every error is `{ "error": { "code", "message", "doc_url", "retryable" } }`. `doc_url` points at the code's entry on [Error codes](/docs/errors).

Updated 2026-09-29. This page as HTML: https://arettic.com/docs/recommend · Markdown: https://arettic.com/docs/recommend.md · JSON: https://arettic.com/docs/recommend.json
