{
  "markdown": "# fezo-skills\n\n`fezo-skills` is an agent skill (`fezo`) backed by a small, dependency-free\nTypeScript CLI (`fezoctl`). The skill discovers and calls Fezo API gateway\ntools by reading the gateway's live catalog (`GET /v1/catalog`) at run time,\ninstead of shipping a hand-maintained, per-backend method roster.\n\nThe skill itself (`skills/fezo/SKILL.md`) is written for an agent and tells it\n*when* to reach for `fezo` and how to run the procedure. This document is\nwritten for the human who installs and operates `fezoctl` — what to run, what\neach flag does, what a failure means, and where the rough edges are.\n\n## Why \"live catalog\" matters\n\nNew backends and methods become callable the moment they register with the\ngateway and show up in `/v1/catalog` — no new release of this skill or this\nCLI is required to recognize them. `fezoctl` never transcribes a backend's\nmanifest into a fixture; it fetches the catalog fresh on every invocation and\nbuilds its candidate list from that response alone. See\n[\"The dynamic flow\"](#the-dynamic-flow) below.\n\n## Requirements\n\n- Node.js >= 22.12 (pinned in `package.json`'s `engines`; `SKILL.md`'s\n  `compatibility` field says \"node >=22\" — treat the `package.json` value as\n  authoritative).\n- bash or zsh, and network access to your Fezo gateway.\n- [pnpm](https://pnpm.io/) — only if you are developing this repository, not\n  to use the CLI.\n\n## Installation\n\nEach host has a first-class lane; `npx skills add` is the cross-host fallback\nthat covers everything else.\n\n| Surface | Install |\n| --------- | --------- |\n| **Claude Code** (recommended) | `/plugin marketplace add 0xPolygonID/fezo-skills` |\n| **Grok** (xAI Build CLI) | `grok plugin marketplace add 0xPolygonID/fezo-skills`, then `grok plugin install fezo` |\n| **Gemini CLI** | `gemini extensions install https://github.com/0xPolygonID/fezo-skills` |\n| **Codex** | `npx skills add 0xPolygonID/fezo-skills -g -a codex` — step-by-step install and test guide in [`CODEX.md`](CODEX.md) |\n| **Cursor, Copilot, and 70+ other hosts** | `npx skills add 0xPolygonID/fezo-skills -g` |\n| **OpenClaw** | `openclaw skills install git:0xPolygonID/fezo-skills@main` |\n| **Hermes** | copy `skills/fezo/` into `~/.hermes/skills/` |\n| **claude.ai / Cowork** (web) | zip `skills/fezo/` and upload — see [caveats](#claude-cowork--claudeai) |\n\nEvery lane installs the same skill directory and the same engine; they differ\nonly in who discovers the manifest. The manifests are generated — see\n[\"Per-host manifests\"](#per-host-manifests).\n\nWhichever lane you use, credentials are a separate one-time step: see\n[\"Credentials\"](#credentials-once-per-machine) below.\n\n### Cross-host: `npx skills add`\n\n```bash\nnpx skills add 0xPolygonID/fezo-skills -g\n```\n\n[`skills`](https://github.com/vercel-labs/skills) reads `skills/fezo/` out of\nthis repository and installs it into every coding agent it detects on your\nmachine — Claude Code, Codex, Cursor, OpenClaw, and others — with the\ncanonical copy at `~/.agents/skills/fezo` and each agent's own skills\ndirectory symlinked to it. Drop `-g` to install into the current project\ninstead, and add `-a claude-code` (repeatable) to target specific agents.\n\nThis works because the skill directory is **self-contained**: it carries its\nown engine at `skills/fezo/scripts/fezoctl.mjs`, a committed copy of\n`dist/fezoctl.mjs`. Installers of this kind copy the skill directory and\nnothing else, so tier 3 of the [invocation ladder](#the-invocation-ladder)\ncannot resolve at the install target and tier 2 is what serves them.\n\n### Credentials: once per machine\n\nNo install lane configures credentials. `skills add` and the plugin\nmarketplaces copy files and run no install hooks, deliberately — so do this\nonce, yourself, after installing by any route (see\n[`CONFIGURATION.md`](CONFIGURATION.md) for the full story):\n\n```bash\nprintf '%s' \"$YOUR_FEZO_API_KEY\" | node ~/.agents/skills/fezo/scripts/fezoctl.mjs setup\nnode ~/.agents/skills/fezo/scripts/fezoctl.mjs doctor\n```\n\nOnly the API key is required. The gateway URL defaults to\n`https://fezo.ai`; add\n`--url https://your-gateway.example.com` if you are on a different one.\n\nCredentials live outside the skill directory (`~/.config/fezo/.env` at mode\n0600, the macOS Keychain, or the environment), so **every host on the machine\nshares one setup** — installing a second lane does not mean configuring again.\n\nTwo exceptions worth knowing:\n\n- **Gemini CLI** takes `FEZO_URL` and `FEZO_API_KEY` as extension settings and\n  injects them as environment variables, which is the highest-priority source\n  in the resolution chain. That lane needs no `setup` run.\n- **claude.ai / Cowork** has no persistent home directory and no way to inject\n  environment variables — see the caveats below.\n\nNode.js >= 22.12 and network access to your gateway must be available to\nwhatever agent runs the skill; `doctor` is the check for both, and it reports\nwhich source each credential resolved from.\n\n### Per-host manifests\n\nThe plugin lanes in the table above are driven by manifests at the repository\nroot, all **generated** by `pnpm gen-manifests`:\n\n| File | Lane |\n| ------ | ------ |\n| `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` | Claude Code |\n| `.codex-plugin/plugin.json` | Codex |\n| `.grok-plugin/plugin.json`, `.grok-plugin/marketplace.json` | Grok / xAI Build CLI |\n| `.agents/plugins/marketplace.json` | cross-host `.agents` marketplace |\n| `gemini-extension.json` | Gemini CLI |\n| `.skillignore`, `.clawhubignore` | Hermes / ClawHub root-scan exclusions |\n\n**Do not hand-edit the generated files.** Every value in them — version,\ndescription, URLs, license — is derived from `package.json` and\n`build/gen-skill.mjs`, so a manual edit is reverted by the next\n`pnpm gen-manifests` and fails CI's manifest-freshness gate in the meantime.\nThey are generated precisely because seven copies of the version number is\nseven ways to publish a release that was never cut.\n\n`.claude-plugin/marketplace.json` earns its keep twice: Claude Code installs\nfrom it, and the `skills` CLI also discovers skills declared in it, so the two\nlanes share one file. The skill is deduplicated by name, so it is not\ndouble-listed.\n\n### Other ways\n\nThere is no published npm package yet — see [\"Known gaps\"](#known-gaps).\n\n- **Copy or symlink the skill directory.** `cp -R skills/fezo\n  ~/.claude/skills/fezo` (or `~/.agents/skills/`, `~/.hermes/skills/`,\n  `~/.openclaw/skills/`, …). A symlink to a checkout works too, and keeps the\n  skill updated by `git pull`.\n- **Use the skill straight from a checkout.** Point your agent at\n  `skills/fezo/SKILL.md` in a clone. Tier 2 resolves the engine; tier 3 is the\n  same bundle one directory up.\n- **Run the committed bundle directly.** `dist/fezoctl.mjs` is a\n  self-contained, deterministic build committed to this repository — clone it\n  and run `node dist/fezoctl.mjs <command>`.\n- **Build from source.** `pnpm install && pnpm bundle` produces\n  `dist/fezoctl.mjs` from `src/cli.ts` and refreshes the skill-local copy.\n\n### Claude Cowork / claude.ai\n\n```bash\ncd skills && zip -r fezo.zip fezo\n```\n\nUpload via **Customize → Skills → + → Create skill → Upload a skill**, and\nenable *Code execution and file creation* under Capabilities. Tier 2 of the\nladder resolves, because the engine rides inside the zip.\n\nExpect friction here, and verify before relying on it. Three things must hold\nin that container, and none is under your control:\n\n1. **Node.js >= 22.12** — the container's version is not documented.\n2. **Network egress to your gateway** — the sandbox restricts outbound\n   connections, so an arbitrary gateway host is normally unreachable unless it\n   has been allowlisted.\n3. **Credentials** — there is no way to inject `FEZO_API_KEY` into that\n   container, and `~/.config/fezo/.env` does not persist between sessions, so\n   `setup` has to be re-run each session. That means the key passes through the\n   conversation, which is exactly what the stdin-only key channel exists to\n   avoid everywhere else.\n\nRun `doctor` first; it names which of the three failed. Until egress and a\ncredential path are settled, treat this lane as unsupported rather than\nbroken-in-an-interesting-way.\n\n### The invocation ladder\n\n`SKILL.md`'s \"Resolve fezoctl\" step (generated from `build/invocation.sh`)\nresolves the `fezoctl` executable in a fixed order, and every subsequent\ncommand in that skill session is invoked through the result\n(`\"${FEZOCTL_ARGV[@]}\"`), never as a bare `fezoctl`:\n\n1. `$FEZOCTL`, if it names an executable file.\n2. `<skill dir>/scripts/fezoctl.mjs` — the bundle committed inside the skill\n   directory — invoked as `node <path>`, not relied on to be executable (a\n   `.skill` archive or plain file copy may not preserve the executable bit).\n   **This is the rung that serves installed skills**, wherever they came from:\n   `npx skills add`, a `cp -R`, an archive.\n3. `<skill dir>/../../dist/fezoctl.mjs` — this repo's own committed bundle,\n   when the skill is used straight out of a checkout — also invoked as\n   `node <path>`. Only a checkout has this layout; an installed skill\n   directory does not, which is why tier 2 exists.\n4. A global `fezoctl` on `PATH`, but **only** if `fezoctl --version` matches\n   `SKILL_VERSION` **exactly**. `fezoctl --version` prints `fezoctl <version>`\n   (a prefixed string, not a bare version), so the ladder compares against\n   `\"fezoctl $SKILL_VERSION\"`, not against `$SKILL_VERSION` alone — a stale or\n   differently-versioned global install is skipped, not silently used.\n5. A version-pinned `npx -y fezo-skills@$SKILL_VERSION fezoctl`.\n\nTiers 2–3 (a versioned bundle shipped with the skill or checked out alongside\nit) always outrank tier 4 (`PATH`): a bundle known to match the skill's own\nversion is preferred over whatever happens to be globally installed, even if\nsomething is on `PATH`.\n\n**Tier 5 does not work today.** `fezo-skills` is not yet published to npm (see\n[\"Known gaps\"](#known-gaps)), so `npx -y fezo-skills@<version>` resolves to a\nversion that does not exist on the registry and will fail. Tiers 1–4 (a\ncheckout, the committed bundle, or a matching global install) are the only\npaths that currently work.\n\n### One version number, not two\n\nThe skill's frontmatter `version` and `package.json`'s `version` are the same\nrelease number, asserted equal by CI (`tests/skill_contract.test.ts`).\n`$SKILL_VERSION` is derived from this single number and used for two things\nthat are both facts about the *package*: the tier-5 `npx` pin above, and the\ntier-4 exact-match comparison against a global install's `--version` output.\nThe per-host plugin manifests carry that same number, which is why they are\ngenerated rather than hand-written. If you bump `package.json`'s `version`, you\nmust re-run and commit **all three**:\n\n```bash\npnpm bundle        # rebuilds both committed bundles with the new version baked in\npnpm gen-skill     # regenerates skills/fezo/SKILL.md with the new SKILL_VERSION\npnpm gen-manifests # regenerates every per-host plugin manifest\n```\n\nBumping the version changes `dist/fezoctl.mjs`'s bytes (the version is baked\nin via an esbuild `--define`, not read from `package.json` at run time in the\nbundled artifact), so CI's freshness gates will fail until all three commands\nare re-run and the results committed.\n\n## Quick start\n\n```bash\n# From a checkout of this repository:\nnode dist/fezoctl.mjs --help\n\n# Configure the API key once — the gateway URL defaults to\n# https://fezo.ai, so --url is only for a different\n# gateway (see CONFIGURATION.md for the full story):\nprintf '%s' \"$YOUR_FEZO_API_KEY\" | node dist/fezoctl.mjs setup\n\n# Confirm everything is wired up:\nnode dist/fezoctl.mjs doctor\n\n# Search, inspect, and call:\nnode dist/fezoctl.mjs search \"web search\"\nnode dist/fezoctl.mjs schema exa_search\nnode dist/fezoctl.mjs call exa_search --args-json '{\"query\":\"anthropic claude\",\"numResults\":3}'\n```\n\nReal output from a session against a gateway (backend and tool names vary by\nwhat your gateway has registered — never hardcode these):\n\n```\n$ node dist/fezoctl.mjs call exa_search --args-json '{\"query\":\"anthropic claude\",\"numResults\":3}'\ncall exa_search\nresolved: exa.search (POST /search)\nrequest: {\n  \"path\": \"/search\",\n  \"query\": {\n    \"query\": \"anthropic claude\"\n  },\n  \"headers\": {},\n  \"body\": {\n    \"numResults\": 3\n  }\n}\nattempts:\n  1. exa_search (exa) [success] billed=true httpStatus=200 — 200 response\n  billing: every attempt that reached a 2xx response is billed by the provider (billed=true); attempts that failed or were skipped before a request was sent are not billed\nbilled: true\nresult (status 200):\n{\n  \"results\": [ ... ]\n}\n```\n\nNotice `query` went into the query string and `numResults` went into the JSON\nbody of the same POST call — see [\"HTTP binding behavior\"](#http-binding-behavior).\n\n## The dynamic flow\n\n```text\nuser intent -> search live catalog -> inspect schema/bindings -> choose candidate\n            -> call -> retry another compatible candidate on retryable failure\n```\n\n- `fezoctl search \"<query>\"` fetches `/v1/catalog` and ranks matching tools.\n  The searchable text is six fields (`src/engine/rank.ts`'s\n  `searchableBlob`): tool name, **backend id**, method name, **title**,\n  description, and backend info text (the backend's `info` title, summary, and\n  description — and *only* those three). Nothing else in the catalog is\n  matchable: verified that a term appearing only in `info.docs_url`, only in\n  `info.categories`, or only as an input-schema property name returns no\n  matches. That exclusion is deliberate (`src/engine/catalog.ts`'s\n  `formatBackendInfoText`) — a `docs_url` ending in `/scrape` would otherwise\n  match \"scrape\" on a backend that cannot scrape.\n- `fezoctl schema <tool>` shows one tool's input schema, HTTP verb, binding\n  map (query/path/header/body), backend id, method name, and call path — the\n  information you need before calling it correctly.\n- `fezoctl call <tool> --args-json '<json>'` resolves exactly the named tool\n  from the live catalog, validates arguments, binds them per the catalog's\n  HTTP binding rules, and calls it once (no retry, no candidate selection).\n- `fezoctl run \"<intent>\" --args-json '<json>'` is the retrying, policy-driven\n  variant: it searches, picks the best-ranked candidate for the intent\n  (honoring provider preference hints — see\n  [\"Provider selection\"](#provider-selection)), calls it, and on a *retryable\n  mechanical* failure tries the next compatible candidate.\n- `fezoctl web-search \"<query>\"` / `scrape <url>` / `crawl <url>` skip search\n  and selection entirely: each walks the **declared** per-intent provider\n  ranking top-down (not a `search` match), trying one provider at a time and\n  falling back on a retryable failure — see [\"One-step\n  commands\"](#one-step-commands).\n- `fezoctl providers [--intent <intent>]` / `list-providers` surface that same\n  declared ranking directly, for comparing providers or reaching a capability\n  (news, social, proxy) no one-step command covers — see [\"Provider\n  recommendations\"](#provider-recommendations).\n\nBecause every one of these commands fetches `/v1/catalog` fresh, a backend\nregistering with the gateway today is discoverable by `search`/`schema`/`run`\ntoday, with no new release of this CLI or skill. This is the central design\nclaim of `fezo-skills`: the catalog is the source of truth, and this repo\nnever hardcodes a backend roster (see `skills/fezo/SKILL.md`'s own\ninstruction not to assume one).\n\n## CLI reference\n\n```\nfezoctl search \"<query>\" [--schema] [--json]\nfezoctl schema <tool> [--json]\nfezoctl call <tool> --args-json '<json>' [--body-json '<json>'] [--json]\nfezoctl run \"<intent>\" --args-json '<json>' [--body-json '<json>']\n           [--max-attempts N] [--retry-empty-2xx] [--allow-unhinted-auto-pick] [--json]\nfezoctl web-search \"<query>\" [--extra-json '<json>'] [--max-attempts N] [--json]\nfezoctl scrape <url>         [--extra-json '<json>'] [--max-attempts N] [--json]\nfezoctl crawl <url>          [--extra-json '<json>'] [--max-attempts N] [--json]\nfezoctl catalog [--json]\nfezoctl providers [--intent <intent>] [--detail names|descriptions|schema]\n                   [--limit N] [--explain] [--json]\nfezoctl list-providers [--json]\nfezoctl setup [--url <url>] [--storage keychain|dotenv] [--json]\nfezoctl doctor [--json]\nfezoctl --version\nfezoctl --help\n```\n\n(Verbatim from `node dist/fezoctl.mjs --help`, which is the authoritative\nsource — run it yourself if this ever looks stale.)\n\n| Command | Purpose |\n| --- | --- |\n| `search \"<query>\" [--schema]` | Fetch the catalog, rank matching tools, optionally include each match's schema and HTTP bindings. |\n| `schema <tool>` | Print one tool's input/output schema, HTTP verb, binding map, backend id, method name, and call path. |\n| `call <tool> --args-json '<json>' [--body-json '<json>']` | Resolve exactly one named tool, validate, bind, and call it once. |\n| `run \"<intent>\" --args-json '<json>' [--body-json '<json>']` | Search, select the best candidate for the intent, call it, and retry a compatible alternative on a retryable mechanical failure. |\n| `web-search \"<query>\"` / `scrape <url>` / `crawl <url>` | One-step commands: walk `src/engine/providers.ts`'s declared ranking for `search`/`scrape`/`crawl` top-down, calling one provider at a time and falling back to the next on a retryable failure — no need to know any provider's argument name. See [\"One-step commands\"](#one-step-commands). |\n| `providers [--intent <intent>]` | Surface the declared, per-intent provider ranking, grouped by capability — every group by default, or exactly one with `--intent`. See [\"Provider recommendations\"](#provider-recommendations). |\n| `list-providers` | One row per live catalog backend, with its declared standing across every intent it appears in. See [\"Provider recommendations\"](#provider-recommendations). |\n| `catalog` | List every backend and method the gateway currently reports. |\n| `setup` | Store the API key (and optionally the gateway URL) without ever putting the key in argv or a transcript. The key is read from stdin — `printf '%s' \"$KEY\" | fezoctl setup` is the whole command, and a key passed as an argument is refused with exit 1. `--url` is optional — omit it and the gateway stays at the built-in default (`configured url: … (source: default)`), which is a complete configuration. A `setup` that stores no API key is not: it prints `this configuration is NOT usable yet: fezoctl needs an API key.` and exits 2 rather than reporting a success no other command can use. |\n| `doctor` | Diagnose configuration and connectivity — the first thing to run when something is wrong. See [\"`doctor`\"](#doctor). |\n\n### Exit codes\n\nFrom `src/cli.ts`'s `HELP_TEXT` (and matching its `EXIT_OK`/`EXIT_USAGE`/`EXIT_OPERATIONAL` constants):\n\n| Code | Meaning |\n| --- | --- |\n| `0` | Success. |\n| `1` | Usage error: a bad command/flag, or an unparseable `--args-json`/`--body-json`/`--extra-json` payload, or an unknown/invalid `providers` `--intent`/`--detail`/`--limit`. Rejected while parsing argv, **before** any candidate is selected or called. |\n| `2` | Operational failure: credentials not configured, the gateway/catalog could not be reached or read, arguments failed schema validation, a `schema`/`call`/`run` that named a deny-listed backend, or a `call`/`run`/`web-search`/`scrape`/`crawl` that did not end in success (including a `run` refusal, an empty match, a one-step walk with no provider left to serve it, or `doctor` finding a hard failure). |\n\nVerified: `--help`/no-args exit `0`; an unknown command, missing `--args-json`,\ninvalid JSON, or `--max-attempts 0` exit `1`; missing credentials, an\nunresolved tool, failed schema validation, and a `run` that gives up or is\nrefused all exit `2`.\n\n## HTTP binding behavior\n\n`fezoctl` reads the catalog's `http` block for each method and places your\narguments accordingly — in the URL path, the query string, a request header,\nor the JSON body — **based on what the catalog says, not on what the HTTP\nverb implies.** This is a deliberate fix for a real bug in the gateway's\nexisting MCP server, which assumes GET means \"all args in query\" and POST\nmeans \"all args in body.\" That assumption breaks down in practice: **a POST can\nlegitimately require query parameters.** For example, a backend's async\nscrape method may be a POST whose `http.query` binding carries an id the\nbackend reads from `r.URL.Query()`, while the POST body is an entirely\nseparate payload the input schema doesn't even describe.\n\nBinding rules, in brief (see `src/engine/bindings.ts` for the exact logic):\n\n- **Path parameters** come from `{placeholder}` segments in the catalog path,\n  URL-encoded per segment, and removed from the remaining argument object.\n- **Query parameters** come from the catalog's `http.query` list.\n- **Header parameters** come from `http.header` — and only from that list:\n  `fezoctl` refuses (throws a local error) to let a tool call set\n  `Authorization` or any `X-Fezo-*` header, even if a manifest names one.\n- **Body** comes from whatever isn't claimed by path/query/header (for a\n  POST-like method), or from `--body-json` when you supply it.\n- If a method's catalog entry has no `http` block at all, `fezoctl` falls\n  back to the legacy assumption (GET → query, POST → body) — but this is\n  the exception, not the rule, and is exactly the behavior the binding logic\n  otherwise avoids.\n\nMissing a required path/query/header/body value is a **local client error**:\n`fezoctl` never sends a request that some other value should have completed.\nIn a `run`, this kind of local rejection does not abort the whole run — it\njust skips that one candidate (see [\"Retry behavior and billing\"](#retry-behavior-and-billing)).\n\n### `--body-json` and the three-branch body-source rule\n\n`--body-json` is how you supply a request body that isn't simply \"the\nremaining arguments as JSON\" — needed whenever a method's body shape is\ndistinct from its query/path/header arguments (the async-scrape example\nabove is the canonical case). The rule, applied in this order:\n\n1. **`--body-json` given:** it is sent as the body, verbatim. A GET method\n   refuses this outright (`body-not-allowed`) rather than silently dropping\n   the body, because the Fetch API cannot send a body on GET/HEAD.\n2. **`--body-json` absent, and the method has both non-body bindings (query/\n   path/header) and a request-body binding:** the bound values are pulled out\n   of `--args-json` for the path/query/header, and whatever is left over in\n   `--args-json` becomes the JSON body.\n3. **`--body-json` absent, and there is no such \"mixed\" shape** (a plain POST\n   with nothing bound to query/path/header, or a plain GET): `--args-json` is\n   the sole source, used for whichever destination the method actually has.\n\nCase 2 and case 3's \"POST\" sub-case are the same mechanism in the\nimplementation — a plain POST is just the \"mixed\" case where nothing happened\nto be claimed by query/path/header, so the whole object is left over as body.\n\nVerified example (a POST method whose query binding claims `dataset_id`, and\nwhose body binding is a separate array the input schema does not describe):\n\n```\n$ node dist/fezoctl.mjs call brightdata_scrape_async \\\n    --args-json '{\"dataset_id\":\"gd_l1\"}' \\\n    --body-json '[{\"url\":\"https://example.com\"}]'\n...\nrequest: {\n  \"path\": \"/scrape_async\",\n  \"query\": { \"dataset_id\": \"gd_l1\" },\n  \"headers\": {},\n  \"body\": [ { \"url\": \"https://example.com\" } ]\n}\n```\n\n## Retry behavior and billing\n\n**Every 2xx response is billed by the provider.** `fezoctl run` makes this\nexplicit rather than hiding it behind automatic retries:\n\n- `--max-attempts` defaults to **2** and bounds **billed calls**, not log\n  entries. A candidate that a local binding check rejects before any request\n  is sent (a missing required argument for *that specific* candidate) costs\n  nothing and does not count against the budget — but it still appears in the\n  attempt log, so you can see why it was skipped. A run can therefore log\n  more attempts than `--max-attempts` if some of them were free pre-flight\n  rejections.\n- **A gateway pre-check rejection is free too, and likewise does not spend the\n  budget**: `provider_disabled`, `backend_unavailable`,\n  `backend_not_configured`, and `backend_not_found` are all written by the\n  gateway *before* the call is forwarded to the provider, so nothing was\n  billed. This matters because `/v1/catalog` does not filter out providers you\n  have disabled on your account — the walk can only discover them by calling\n  them — so without the exemption an account with its top-ranked providers\n  switched off would spend the whole budget on free 403s and give up before\n  reaching a provider that is actually enabled. The count is reported as\n  `unbilled_rejections`, and the text output names each one so you can\n  re-enable it (or fix its required settings). Codes that can follow a\n  forwarded request — `backend_error` (which the gateway also writes on a\n  502 after forwarding), `quota_exceeded`, `rate_limited` — are *not* exempt.\n- Each attempt in the log carries a `billed` field, set from the actual\n  response received — `true` for any 2xx, `false` otherwise — never inferred\n  from the attempt's `status`. (A `retry`-status attempt caused by an empty\n  2xx body is still `billed: true`, because the empty response was already\n  paid for.)\n- **Empty-2xx retry is opt-in**, via `--retry-empty-2xx`, precisely because\n  retrying spends money on a response that may simply be legitimately empty.\n  Without the flag, an empty 2xx body counts as `success`.\n- **Semantic-quality retries are never automatic.** The engine only detects\n  *mechanical* failure (a non-2xx response, a transport error, a local\n  binding rejection). Whether a successful response is actually useful — on\n  topic, complete, not a bot-block page — is the agent's judgment call;\n  `SKILL.md` instructs the agent to inspect the result and deliberately call\n  a different candidate if it isn't.\n\n### Classification (why one failure retries and another aborts)\n\nClassification is by **gateway error code first, HTTP status only as a\nfallback** when there is no code (`src/engine/retry.ts`):\n\n- **Abort the whole run:** `unauthorized`, `limit_exceeded`,\n  `insufficient_balance` — these describe the caller's account or\n  credentials, not one provider, so trying another candidate cannot help.\n  Known limitation: `limit_exceeded` can, in principle, be scoped to a single\n  backend, but the gateway currently exposes that scope only in a\n  human-readable message, not a structured field — so `fezoctl` aborts\n  conservatively even when a backend-scoped limit could have safely advanced\n  to another provider.\n- **Try the next candidate:** `quota_exceeded`, `rate_limited`,\n  `backend_unavailable`, `provider_disabled`, `backend_not_configured`,\n  `backend_not_found`, `backend_error`, `tool_not_in_catalog`, a code-less\n  HTTP 402/429/500/502/503, a transport failure, and (opt-in) an empty 2xx\n  body. Note: `rate_limited` as a *gateway* code is not normally observed in\n  practice — a real upstream rate limit almost always arrives as a code-less\n  backend 429, so the HTTP-status fallback is the path that actually matters\n  for rate limiting.\n- **Give up:** an **unrecognized** gateway code (one in neither the abort nor\n  the retry set above — `fezoctl` will not guess what an unknown code means);\n  a code-less response whose status is **anything** outside 402/429/500/502/503\n  (so a code-less 404 *and* a code-less 504 both give up — the rule is not\n  scoped to 4xx); or an exhausted candidate list.\n- A **local binding rejection** (a candidate-specific missing argument, a\n  disallowed header) is *never* an abort — it just skips that one candidate,\n  because it says nothing about whether the next candidate (which may name\n  its parameters differently, or use a different HTTP verb) would also fail.\n\nVerified fallback example — a preferred backend fails with a code-less 503,\nand `run` advances to the next preferred candidate for the same capability.\n(`position 2` is firecrawl's index in the declared `scrape` order, which\nbegins `scrapingdog` → `brightdata` → `firecrawl`; neither of the first two is\nin this catalog, so firecrawl is the highest-ranked provider actually present.\nBefore this CLI derived its preference from the declared table, the same run\nreported `position 0` against a hand-written list that began with firecrawl —\nso a `run` whose catalog *does* carry `scrapingdog` or `brightdata` will now\npick one of those first.)\n\n```\n$ node dist/fezoctl.mjs run \"scrape url\" --args-json '{\"url\":\"https://example.com\"}'\nrun \"scrape url\"\nselected: firecrawl_scrape (firecrawl.scrape, POST /scrape, per_call)\n  why: exact-method; matched: scrape, url; termScore=4; preferred for \"scrape\" (position 2)\nattempts:\n  1. firecrawl_scrape (firecrawl) [retry] billed=false httpStatus=503 — code-less HTTP 503\n  2. scrapingbee_scrape (scrapingbee) [success] billed=true httpStatus=200 — 200 response\n  billing: every attempt that reached a 2xx response is billed by the provider (billed=true); attempts that failed or were skipped before a request was sent are not billed\nbilled: true\nresult (status 200):\n{\n  \"markdown\": \"# Example\\ncontent\"\n}\n```\n\nVerified give-up example — the same intent and the same candidate list, but\nthe first attempt returns a gateway code that is in neither classification\nset. `run` stops there rather than spending money on `scrapingbee`, because an\nunknown code is not evidence that another provider would do better:\n\n```\n$ node dist/fezoctl.mjs run \"scrape url\" --args-json '{\"url\":\"https://example.com\"}'\nrun \"scrape url\"\nselected: firecrawl_scrape (firecrawl.scrape, POST /scrape, per_call)\n  why: exact-method; matched: scrape, url; termScore=4; preferred for \"scrape\" (position 2)\nattempts:\n  1. firecrawl_scrape (firecrawl) [give_up] billed=false httpStatus=400 gatewayCode=malformed_request — unrecognized gateway code \"malformed_request\"\n  billing: every attempt that reached a 2xx response is billed by the provider (billed=true); attempts that failed or were skipped before a request was sent are not billed\nbilled: false\ngive up: unrecognized gateway code \"malformed_request\"\n```\n\nA code-less response gives up the same way, with `non-retryable HTTP <status>\nwith no gateway code` — verified for both a code-less 404 and a code-less 504.\n\n### Known limitation: a dropped connection during a billed response\n\nIf the connection drops while `fezoctl` is reading the **body** of an\nalready-2xx response, the gateway has already recorded the billing event\n(it bills before copying the response body), but `fezoctl` cannot tell that\napart from a pre-response transport failure. That attempt is logged as a\n`transport` failure with `billed: false` — **even though you may have been\ncharged.** This is a documented, accepted gap (`src/engine/retry.ts`'s\n`attemptCandidate` doc comment), not something `fezoctl` currently detects or\ncorrects for.\n\n## Provider recommendations\n\nThe per-capability (\"intent\") ordering `providers`/`list-providers` return,\nand that `web-search`/`scrape`/`crawl` walk, is declared by hand in\n[`src/engine/providers.ts`](src/engine/providers.ts)'s `RECOMMENDATIONS` —\n**array order is rank; nothing is scored or sorted at runtime.** It is the\n*conclusions* of [`docs/providers-score.md`](docs/providers-score.md) (the\nunderlying five-criterion rubric, its weights, and its arithmetic) written\ndown directly, ported verbatim from `zug/mcp-server`'s own declared table —\nincluding its rationale comments — because writing the order down removes\nfloat-rounding and tie-break machinery that exists only to reproduce twelve\nnumbers whose gaps are false precision anyway (`brave` 80.6 vs `exa` 79.5 — a\n1.1-point gap from integer judgments on five axes). The rubric survives as an\naudit trail, not as the mechanism. **Provider policy is edited in\n`src/engine/providers.ts`, nowhere else** — see [\"Provider\nselection\"](#provider-selection) for how `run`'s legacy tie-break table is\njust a derived view of this same one.\n\nCurrent declared order per intent (`primary` → `secondary` → `fallback`; a\n`when` note tells you when to skip ahead a rank):\n\n| Intent | Order |\n| --- | --- |\n| `search` | `you` → `exa` (semantic/neural retrieval) → `brave` (independent index/data sovereignty) → `firecrawl` → `geonode` |\n| `scrape` | `scrapingdog` → `brightdata` (hard/anti-bot targets, or Scrapingdog success <~50%) → `firecrawl` → `geonode` → `apify` → `scraperapi` → `scrapingbee` |\n| `crawl` | `firecrawl` → `geonode` → `brightdata` → `apify` |\n| `news` | `newsapi` → `you` → `brave` |\n| `social` | `apify` and `brightdata` (both primary) → `xro` (not recommended: ~30–90× costlier than third-party alternatives, hard 2M-read cap, heaviest TOS/lock-in risk) |\n| `proxy` | `geonode` → `brightdata` |\n\nThis table is a snapshot for orientation, not the thing to script against —\n`fezoctl providers`/`list-providers` read the live, canonical table and also\ntell you what is actually *callable on your gateway right now*, which this\nstatic list cannot.\n\n**Providers are not substitutes across these groups.** They span four\nfunctional categories (AI search, scraping, proxy infrastructure,\nspecialized/social) — a global cross-capability ranking would put an AI-search\nprovider above a proxy/unlocking provider and point you at the wrong tool for\na Cloudflare-protected page. Always compare *within* one capability group:\npass `--intent` to `providers` to get exactly one such group.\n\nTwo cases that look similar but are not, both always surfaced (never\ndropped):\n\n- **Unrated** (`rated: false`) — a live catalog backend that no declared list\n  mentions at all, e.g. a newly onboarded backend. Appended after every\n  declared provider with a note that it is simply not yet assessed.\n- **Not recommended** (`not_recommended.reason` present) — assessed and\n  advised against; currently only `xro` for `social`. Placed last in its\n  declared list.\n\nAn unrated backend therefore sorts ahead of a not-recommended one, and\n`best_value` (the group's top pick) is present only when rank 1 is a\ndeclared, not-advised-against recommendation — never for an unrated backend,\nand never for the `other` group (which has no declared recommendations at\nall).\n\nVerified — `providers --intent search` against a two-backend catalog (`you`\npublishes no live `search` method, so its row falls back to a few of its\nother catalog method names rather than showing nothing callable; `exa`\npublishes its declared entry method):\n\n```\n$ node dist/fezoctl.mjs providers --intent search\nrecommendations: docs/providers-score.md (prepared 2026-08-05)\n\nsearch — best_value: you\n  1. [primary] You.com (you, dynamic)\n     methods: [you_contents, you_finance_research, you_research] (+1 more)\n  2. [secondary] Exa (exa, per_call)\n     entry_methods: [exa_search]\n```\n\n`list-providers` inverts the view — one row per live backend, every intent it\nhas a declared standing in:\n\n```\n$ node dist/fezoctl.mjs list-providers\nrecommendations: docs/providers-score.md (prepared 2026-08-05)\nproviders — 2 backend(s)\n  You.com (you, dynamic)\n    why: cheapest quality AI search, clean data rights\n    categories: []\n    methods: [you_contents, you_finance_research, you_research, you_research_start]\n    recommendations:\n      search: declared rank 1 (primary) — cheapest quality AI search, clean data rights\n      news: declared rank 2 (secondary) — same clean, cheap index; freshness-filtered search stands in for a dedicated news endpoint\n  Exa (exa, per_call)\n    why: neural/semantic retrieval with deep research and monitors\n    when: semantic/neural retrieval quality matters most\n    categories: []\n    methods: [exa_search]\n    recommendations:\n      search: declared rank 2 (secondary) — neural/semantic retrieval with deep research and monitors\n```\n\n`providers` ranks by what your catalog **actually serves** — a provider's\nnumber moves up when a higher-ranked one is absent from your gateway's\nentitlement — while `list-providers` always reports the **declared** rank,\nthe provider's fixed position in `RECOMMENDATIONS`. The two commands can\ntherefore print different numbers for the same provider; neither is wrong,\nthey answer different questions (\"what should I call right now\" vs. \"where\ndoes this provider stand in the policy\").\n\n`--detail` (on `providers`) defaults to `names` — a cheap sweep carrying each\nrow's identity (rank, tier, provider, backend id, billing model, and the\n`rated` / `not_recommended` flags) plus what is callable; a provider with no\nlive entry method for the intent still shows a few of its catalog method\nnames rather than an empty row, exactly like the `you` example above, and\nreports what that cap dropped as `methods_omitted`. \"Cheap\" means this level\nomits the why/when prose, the complete method list and any inlined schema —\n**not** that it omits identity: the `--json` and human views carry the same\nfields at every level, so a script can see that a provider is advised against\nwithout having to ask for `descriptions`. `descriptions` adds the full\nwhy/when prose and the provider's complete method list; `schema` additionally\ninlines each surfaced method's input schema. `--explain` adds the\n`recommendations` provenance block to every row, at every detail level.\n`--limit` caps each group and always reports what it dropped as `omitted`,\nnever silently.\n\n### Deny-listed backends (`falai`, `alpaca`)\n\n`falai` and `alpaca` never appear in `search`/`catalog`/`providers`/\n`list-providers`, and `schema`/`call`/`run` refuse them by name (exit `2`,\n`backend-excluded`) even when you already know the exact tool name.\n\n`schema` refuses rather than merely filtering, even though it calls nothing\nand bills nothing: handing back a full input schema and binding map for a\nbackend this CLI will then refuse to call just costs you a second command to\nlearn one fact. The message names the action you attempted — `cannot be\ninspected` for `schema`, `cannot be called` for `call`/`run` — and names\n`FEZO_EXCLUDED_BACKENDS`, which is the thing to change if you want it back.\n**This deny-list is currently the only thing disabling either backend** on\nthis CLI's side — `fezoctl` only talks to the gateway over HTTP and cannot\nchange what the gateway itself serves — so treat it as the switch, not as\ndefence in depth.\n\nThe default set (`['falai', 'alpaca']`) is overridden — **not extended** — by\n`FEZO_EXCLUDED_BACKENDS`: a comma-separated backend-id list. An explicitly\nempty string, `FEZO_EXCLUDED_BACKENDS=\"\"`, is honoured as \"exclude nothing\" —\nan *absent* variable is what falls back to the default, so there is no way to\nask for \"the default plus one more\" short of writing out the whole list\nyourself. This is what makes the falai/alpaca call reversible without a\nrelease, in both directions. `web-search`/`scrape`/`crawl`'s ranked walk also\nnever attempts a deny-listed provider, silently skipping past it exactly like\na `notRecommended` one (see [\"One-step commands\"](#one-step-commands)).\n\n### Refresh procedure\n\nRecommendations are prose-to-prose against the source doc; there is no\narithmetic to reconcile until (or unless) a scoring rubric is ever wired up at\nruntime. To refresh:\n\n1. Re-read [`docs/providers-score.md`](docs/providers-score.md).\n2. Update `RECOMMENDATIONS` and `RECOMMENDATION_SOURCE.preparedAt` in\n   `src/engine/providers.ts` to match.\n3. Re-check the invariants `tests/providers.test.ts` pins by hand-editing a\n   fixture, or just re-run `pnpm test` — every intent needs a `primary`;\n   tiers must stay non-increasing down each list; no `backendId` may appear\n   twice within one intent; every `entryMethods` name must be tagged with (at\n   least) its own intent in `src/engine/intent.ts`'s `METHOD_INTENTS`. None of\n   that says the order is *right* — only a re-read of the source doc speaks to\n   that.\n4. Run `fezoctl doctor` against a real gateway afterward — its\n   `preference-hints` check (see [\"`doctor`\"](#doctor)) warns if the refreshed\n   table now names a backend or entry method your catalog doesn't actually\n   publish.\n\n## One-step commands\n\n`web-search \"<query>\"`, `scrape <url>`, and `crawl <url>` are one call each —\nno need to search first, pick a candidate, or know any provider's argument\nname. Each walks [`src/engine/providers.ts`](src/engine/providers.ts)'s\ndeclared ranking for its own intent (`search`/`scrape`/`crawl` respectively)\n**top-down**, in declared order — never re-sorted, never the `search` command's\nrelevance ranking — trying one provider at a time and falling back to the\nnext on a retryable mechanical failure, until one succeeds or the walk is\nexhausted.\n\nVerified fallback — `you` (rank 1 of `search`) fails with a code-less 503,\nand the walk advances to `exa` (rank 2), which succeeds:\n\n```\n$ node dist/fezoctl.mjs web-search \"weather today\"\nweb-search \"weather today\"\nServed by Exa (rank 2 of search). For a different provider, more options, or a capability no one-step command covers (news, social, proxy), run `fezoctl providers --intent search`.\nattempts:\n  1. you_search (you) [retry] billed=false httpStatus=503 gatewayCode=backend_unavailable — gateway code \"backend_unavailable\"\n  2. exa_search (exa) [success] billed=true httpStatus=200 — 200 response\n  billing: every attempt that reached a 2xx response is billed by the provider (billed=true); attempts that failed or were skipped before a request was sent are not billed\nbilled: true\nresult (status 200):\n{\n  \"results\": []\n}\n```\n\nA few rules specific to these three commands:\n\n- **Argument-name resolution is automatic.** Each provider names the same\n  input differently (`query` vs `q` vs `keyword` for `web-search`; `url` vs\n  `target_url` vs `link` for `scrape`/`crawl`) — the walk reads each\n  candidate's own input schema and resolves which property carries your\n  value, preferring a *required* property over an optional one with the same\n  name. A provider whose schema names nothing plausible is skipped, never\n  called with a guessed argument name.\n- **`--extra-json` merges provider-specific options** into whichever\n  candidate the walk lands on (result counts, formats, timeouts — never the\n  query/URL itself, which is always the command's own positional argument).\n  A provider whose own schema rejects the merged arguments is skipped and\n  named under `arg_rejected` — reported **even on an otherwise successful\n  run**, so \"rank 1 was blocked\" and \"your `--extra-json` disqualified rank 1\"\n  never produce identical-looking output.\n- **`manifest_rejected` is the case that is *not* yours to fix.** A provider\n  whose arguments pass its schema but whose manifest then requires a value\n  the command never asks for (a path or query parameter, say) is named here\n  instead — also reported even on a successful run, and deliberately worded\n  to send you to `fezoctl schema <tool>` and `fezoctl call <tool>` rather\n  than to an `--extra-json` you may never have passed. The two are separate\n  fields because only one of them describes something you can change.\n- **`--max-attempts` defaults to 3 here, not `run`'s default of 2** — a\n  deliberately different budget, because the two numbers bound different\n  things: `run`'s budget is a *retry* budget for repeated failures on one\n  already-selected candidate; a one-step command's budget is a\n  *ranked-fallback* budget across several genuinely different,\n  separately-priced providers. Pass `--max-attempts` to override it.\n- **A 60-second wall-clock deadline** bounds the whole walk, not configurable\n  from the command line. It is checked only **before starting a new\n  attempt, never mid-attempt, and never before the first** — a client-side\n  timeout that aborted a call already in flight would discard a result\n  already billed. On expiry the walk stops starting new attempts and reports\n  whichever candidate answered last; the output says the cap stopped it,\n  which reads differently from \"every provider failed\" for exactly the reason\n  a caller needs to know which one happened.\n- **Deny-listed and `notRecommended` providers are never attempted**, and\n  never explain their own absence in the output — they are policy exclusions\n  decided ahead of time, not something this specific call discovered.\n- **No provider could serve the request at all** (every declared provider was\n  absent from your catalog, unranked, or had no resolvable argument) exits\n  `2` and names which providers were skipped and why, pointing you at\n  `fezoctl providers --intent <intent>` to see the full picture.\n- **Each command's one-line description exists once**, in\n  [`src/engine/one-step-descriptions.json`](src/engine/one-step-descriptions.json).\n  `fezoctl --help` renders it (through `src/engine/steering.ts`'s\n  `ONE_STEP_DESCRIPTIONS`) and so does the generated\n  [`skills/fezo/SKILL.md`](skills/fezo/SKILL.md) (through\n  `build/gen-skill.mjs`), each wrapping it to its own column — so the help\n  text an operator reads and the procedure an agent follows cannot end up\n  describing the same command differently. Edit the JSON, then run\n  `pnpm gen-skill`; `tests/skill_contract.test.ts` fails if the committed\n  SKILL.md no longer carries the current sentences.\n\n## Provider selection\n\nWhen `run` has more than one compatible candidate, which one it tries first\nis **policy, not measurement**: `src/engine/preference.ts`'s\n`CAPABILITY_PREFERENCES` is a per-capability backend ordering — recorded\nhuman judgment about which provider to prefer, not a measured cost or latency\nranking. `run` never falls back to alphabetical catalog order as a substitute\npolicy.\n\nCapabilities are **`scrape` and `web-search`** — that's the whole set.\n`serp` is not a capability: it used to be a third one, and was folded into\n`web-search` (its keyword phrases — `\"serp\"`, `\"google search\"`, etc. — moved\nthere verbatim) rather than kept as a separate bucket sharing `web-search`'s\nordering, because the declared table this repo derives from has no SERP-\nspecific list to give it: a Google-SERP request and a general web-search\nrequest are both the `search` intent in `src/engine/providers.ts`, served by\nthe same declared roster. Two capabilities aliased onto one ordering only\nbought a spurious `ambiguous-capability` refusal on \"google search for X on\nthe web\" over a distinction this repo's provider policy does not draw.\n\n`CAPABILITY_PREFERENCES` is **derived, not authored**: it is a view of the\ndeclared per-intent provider table `RECOMMENDATIONS` in\n`src/engine/providers.ts` — see [\"Provider\nrecommendations\"](#provider-recommendations) — with each capability taking\nits intent's declared backend order (`scrape` → `scrape`; `web-search` →\n`search`) and `notRecommended` entries dropped. **Provider policy is edited\nin `src/engine/providers.ts`**, the one authored table in the repo; editing\n`preference.ts` changes only how that table is reshaped into the two legacy\nbuckets `rank.ts` reads.\n\n- A free-text intent is matched against a small keyword table\n  (`CAPABILITY_KEYWORDS`) to infer which capability, if any, applies. If a\n  capability is inferred, its preference ordering breaks ties among matching\n  candidates.\n- Each capability's ordering is **sparse** — a backend absent from it simply\n  gets no preference boost — so an inferred capability can name *none* of the\n  matched candidates' backends. This is exactly the SERP case above in a\n  different guise: a SERP-worded query (`\"google search results\"`) infers\n  `web-search`, but `web-search`'s declared `search` roster (`you` → `exa` →\n  `brave` → `firecrawl` → `geonode`) deliberately names no SERP specialist —\n  zug's provider policy prefers real search APIs over scraping a results\n  page. A hint that discriminates nothing among the actual candidates is\n  treated as **no hint at all** (`rank.ts`'s `selectForRun`, the\n  `discriminates` guard): ranking on it would decide a billed call by\n  input/catalog order while still reporting the result as a hinted `selected`\n  pick, with no `--allow-unhinted-auto-pick` gate in front of it — exactly\n  the \"alphabetical order becomes the policy\" outcome the unhinted rule below\n  exists to prevent.\n- If no usable capability hint applies — the intent's wording doesn't match\n  any known capability phrase, **or** the inferred capability's ordering\n  discriminates nothing among the matched candidates (the case just above) —\n  and the matching candidates span **two or more backends**, `run` **refuses\n  to auto-pick** rather than silently making catalog/alphabetical order the\n  policy. This refusal is overridable with `--allow-unhinted-auto-pick`,\n  which promotes **only the top-ranked candidate** — there is no fallback to\n  a second candidate under this override, because the user only agreed to\n  the one promotion, not to a chain of un-hinted backends. When every\n  matched candidate is from a *single* backend, there is no cross-provider\n  policy to get wrong, so `run` auto-picks even with a non-discriminating (or\n  absent) hint — the guard only ever turns a would-be `selected` into a\n  refusal when two or more backends are actually competing.\n- If two or more capabilities match ambiguously, `run` also refuses — with\n  **no override** for that case.\n- **Async lifecycle methods are excluded from auto-selection by default.** A\n  method that looks like starting, polling, or fetching the result of an\n  asynchronous job is not something `run` will call on your behalf from an\n  ordinary free-text intent. The exclusion is a **default, not a wall** — two\n  things re-enable it (`src/engine/rank.ts`'s `selectForRun`), and when every\n  match was excluded, `run` prints the first one as a hint rather than\n  reporting a bare no-match:\n  1. The intent contains `async`, `job`, `snapshot`, `status`, or `crawl` as a\n     whole word. Async candidates are then eligible for auto-selection like\n     any other — asking for async behavior counts as consent to it.\n  2. The intent exactly matches one tool's name (that candidate alone is\n     re-enabled). `fezoctl call <tool>` always works too, and never applies\n     this filter at all.\n\nVerified refusal and override, generic case (no capability wording matches\nat all):\n\n```\n$ node dist/fezoctl.mjs run \"search\" --args-json '{\"query\":\"x\"}'\nrun \"search\"\nrefused: candidates span multiple backends with no capability preference (exa, newsapi); use --allow-unhinted-auto-pick to pick the top-ranked one, or call a specific tool\n\n$ node dist/fezoctl.mjs run \"search\" --args-json '{\"query\":\"x\"}' --allow-unhinted-auto-pick\nrun \"search\"\nrefused: candidates span multiple backends with no capability preference (exa, newsapi); use --allow-unhinted-auto-pick to pick the top-ranked one, or call a specific tool\n--allow-unhinted-auto-pick set: promoting exa_search (exa.search, POST /search, per_call)\nattempts:\n  1. exa_search (exa) [success] billed=true httpStatus=200 — 200 response\n...\n```\n\nVerified refusal and override, the Amendment-A \"hint discriminates nothing\"\ncase: `\"google search results\"` DOES infer `web-search`, but the two matching\ncandidates (`scraperapi`/`brightdata` SERP endpoints) are both absent from\n`web-search`'s declared `search` roster, so the hint is treated as none and\nthis still lands on the same overridable refusal as the unhinted case above —\nnever on a silent `selected` decided by catalog order:\n\n```\n$ node dist/fezoctl.mjs run \"google search results\" --args-json '{\"q\":\"widgets\"}'\nrun \"google search results\"\nrefused: candidates span multiple backends with no capability preference (scraperapi, brightdata); use --allow-unhinted-auto-pick to pick the top-ranked one, or call a specific tool\n\n$ node dist/fezoctl.mjs run \"google search results\" --args-json '{\"q\":\"widgets\"}' --allow-unhinted-auto-pick\nrun \"google search results\"\nrefused: candidates span multiple backends with no capability preference (scraperapi, brightdata); use --allow-unhinted-auto-pick to pick the top-ranked one, or call a specific tool\n--allow-unhinted-auto-pick set: promoting scraperapi_serp (scraperapi.serp, GET /serp, per_call) — Google SERP\nattempts:\n  1. scraperapi_serp (scraperapi) [success] billed=true httpStatus=200 — 200 response\n...\n```\n\nBoth exit `2` without the flag and `0` with it — identically to the generic\ncase, which is the point: a non-discriminating hint and no hint at all are\nhandled by the exact same rule.\n\nVerified async exclusion, the hint `run` prints, and the intent-word override:\n\n```\n$ node dist/fezoctl.mjs run \"dataset\" --args-json '{\"dataset_id\":\"gd_l1\"}'\nrun \"dataset\"\nevery matching candidate is an async lifecycle method (start/poll/status/fetch-result), so none was auto-picked:\n  - brightdata_scrape_async (brightdata.scrape_async, POST /scrape_async, dynamic)\nname the tool exactly (`fezoctl call <tool>`), or add \"async\"/\"job\"/\"snapshot\"/\"status\"/\"crawl\" to the intent to allow one\n\n$ node dist/fezoctl.mjs run \"dataset job\" --args-json '{\"dataset_id\":\"gd_l1\"}'\nrun \"dataset job\"\nselected: brightdata_scrape_async (brightdata.scrape_async, POST /scrape_async, dynamic)\n  why: term-score; matched: dataset, job; termScore=2\nattempts:\n  1. brightdata_scrape_async (brightdata) [success] billed=true httpStatus=200 — 200 response\n...\n```\n\nThe first form exits `2`, the second `0`. Because the override makes an async\nmethod billable from a free-text intent, prefer `call` when you already know\nwhich method you want.\n\n## The `--json` error contract\n\nWith `--json`, stdout is a JSON document for every command — never empty —\nwhich is what makes it safe for an agent or script to parse unconditionally.\nThere is exactly one exception, noted below.[^help-json] There are two\npossible shapes, and a consumer must handle both:\n\n1. **A failure that never reached the engine** (bad usage, no credentials,\n   catalog unreachable, `schema` naming a tool that isn't in the catalog,\n   schema validation failed, `--version` couldn't read its own version, or\n   `call`/`run` refusing a deny-listed backend by name):\n\n   ```json\n   {\"error\": {\"kind\": \"...\", \"message\": \"...\"}}\n   ```\n\n   `kind` is a **closed set of eight values** (from `src/engine/render.ts`'s\n   `CliErrorKind`, stable — values may be added in the future but never\n   renamed or repurposed): `usage`, `credentials-not-configured`,\n   `catalog-unavailable`, `tool-not-found`, `invalid-args`, `invalid-body`,\n   `version-unavailable`, `backend-excluded`.\n\n2. **A `call`/`run`/`web-search`/`scrape`/`crawl` that reached the engine** —\n   even if the outcome was a retry give-up, an abort, a run refusal, or a\n   one-step walk that never found a provider to serve it — emits its **full\n   attempt-log report** instead of an error envelope, because that document\n   carries strictly more information (the attempt log and what was billed).\n   Look for an `attempts` array and an `outcome`/`result` field, not an\n   `error` key. **This includes `call <tool>` where `<tool>` is not in the\n   catalog**: `call` synthesizes the one-entry attempt log `run` would have\n   produced rather than emitting a `tool-not-found` envelope, so the marker to\n   test is `resolved: false`, not `error`. Only `schema <tool>` produces the\n   `tool-not-found` envelope (`src/cli.ts`'s `cmdSchema` vs. `cmdCall`).\n   **`backend-excluded` is the one exception in the other direction**: a\n   `call`/`run` that resolves to a deny-listed backend refuses BEFORE calling\n   anything, exactly like `tool-not-found`, so it is shape 1 above even though\n   `call`/`run` are otherwise shape-2 commands — see [\"Deny-listed\n   backends\"](#deny-listed-backends-falai-alpaca).\n\nThe human-readable message always goes to **stderr**, in both cases, and the\nexit code does not change based on `--json`.\n\nVerified — shape 1, an argument that fails the resolved tool's schema:\n\n```\n$ node dist/fezoctl.mjs call exa_search --args-json '{}' --json\n{\n  \"error\": {\n    \"kind\": \"invalid-args\",\n    \"message\": \"--args-json does not match exa_search's input schema: (root) must have required property 'query'\"\n  }\n}\n```\n\nVerified — the same unknown tool name through both commands, showing that\n\"tool not found\" is shape 2 under `call` and shape 1 under `schema`. Both exit\n`2`:\n\n```\n$ node dist/fezoctl.mjs call nope_tool --args-json '{}' --json\n{\n  \"tool\": \"nope_tool\",\n  \"resolved\": false,\n  \"attempts\": [\n    {\n      \"tool\": \"nope_tool\",\n      \"backendId\": \"(unresolved)\",\n      \"status\": \"retry\",\n      \"reason\": \"gateway code \\\"tool_not_in_catalog\\\"\",\n      \"billed\": false,\n      \"httpStatus\": 404,\n      \"gatewayCode\": \"tool_not_in_catalog\"\n    }\n  ],\n  \"outcome\": {\n    \"kind\": \"give_up\",\n    \"reason\": \"no more candidates to try\"\n  },\n  \"billedAnyAttempt\": false,\n  \"billing\": \"every attempt that reached a 2xx response is billed by the provider (billed=true); attempts that failed or were skipped before a request was sent are not billed\"\n}\n\n$ node dist/fezoctl.mjs schema nope_tool --json\n{\n  \"error\": {\n    \"kind\": \"tool-not-found\",\n    \"message\": \"tool \\\"nope_tool\\\" was not found in the catalog\"\n  }\n}\n```\n\nVerified — `call` naming a deny-listed backend's tool by its exact name. The\ntool genuinely exists in the live catalog (unlike the `nope_tool` case\nabove), but `fezoctl` refuses to reach it regardless — shape 1, not shape 2,\nbecause no request is ever sent:\n\n```\n$ node dist/fezoctl.mjs call falai_generate --args-json '{\"prompt\":\"a cat\"}' --json\n{\n  \"error\": {\n    \"kind\": \"backend-excluded\",\n    \"message\": \"backend \\\"falai\\\" is excluded (FEZO_EXCLUDED_BACKENDS); \\\"falai_generate\\\" cannot be called\"\n  }\n}\n```\n\n[^help-json]: `fezoctl --help --json` (or `-h` anywhere in argv alongside\n    `--json`) prints the **help text** on stdout and exits `0` — help is\n    resolved before argv is parsed into a command, and it has no JSON form.\n    That output is not parseable JSON, so a script that unconditionally\n    `JSON.parse`es stdout must not pass `--help`/`-h`. Every other command,\n    including a bare `fezoctl --json` (which is a `usage` error, because\n    `--json` is not a command), does emit a JSON document.\n\n## Credentials\n\nOnly the **API key** has to be configured. The gateway URL falls back to a\nbuilt-in default, `https://fezo.ai`, as the last\nrung of its resolution chain — `FEZO_URL`, a Keychain item, and `~/.config/fezo/.env`\nall outrank it, and `doctor`/`setup` report a defaulted URL as `source: default`\nso a gateway nobody chose never looks like one somebody did. The API key has no\ndefault and must not grow one.\n\nSee **[CONFIGURATION.md](CONFIGURATION.md)** for the full credential model:\nthe resolution order, the security reasoning behind\n`setup`, macOS Keychain details, `.env` file location and\npermissions, and **how to rotate a key** —\nwhich differs between the two storage backends, because a second\n`setup` on the default `dotenv` storage is refused rather than\noverwriting the file.\n\n## `doctor`\n\nRun `fezoctl doctor` first whenever something is wrong. It reports a sequence\nof independent checks rather than bailing out at the first failure, so you\ncan see exactly how far configuration and connectivity got:\n\n| Check | Meaning |\n| --- | --- |\n| `gateway-url` | Which source `FEZO_URL` resolved from. Never fails: with none configured it reports `FEZO_URL is not configured; using the built-in default gateway` and `source: default`, which is a working state — see [\"Credentials\"](#credentials). |\n| `api-key` | Whether `FEZO_API_KEY` resolved from any source, and which one. The only credential whose absence is a hard failure. |\n| `gateway-connectivity` | Whether the gateway responded at all to a catalog fetch. |\n| `auth` | Whether the gateway accepted the API key (distinguished from connectivity by a 401/403 status specifically). |\n| `catalog-readable` | Whether the response body actually parsed as a catalog document. |\n| `preference-hints` | Whether every backend AND declared entry method in `src/engine/providers.ts`'s `RECOMMENDATIONS` (all seven intents) is actually present in the live catalog (a `warn`, not a `fail` — a declared row naming a backend/method this gateway does n",
  "bytes": 60000,
  "sha": "c9fd78f81cd68d7af83e829d04de80099d53f99913d2d0805e58b5362b783f74",
  "repo_slug": "fezoai/skills",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/plg_fezoai_skills_6b21e18b/readme"
}