Libraries

Errors Agents Can Act On

Write why, fix, and link so a model calling your package recovers from the error itself, without a docs round-trip and the tokens it costs.

An agent calls a tool built on your package. The call fails. What the model sees next decides whether it recovers in one step or in six: a bare Not authorized reads as "the repository is private" to a model, which then reports a wrong cause with confidence, or opens your documentation and spends a few thousand tokens reading pages that may not name the case at all. A catalog entry with a good why and fix is the page it would have needed, delivered inside the error.

This is the same catalog as Structured Errors. The difference is who reads the fields. A human skims fix and fills in the gaps; a model executes it literally, so the sentences have to survive being taken at their word.

What a model does with each field

FieldWhat the model does with itWhat that means for how you write it
codeMatches it against what it has seen before, in this session or in your llms.txtStable, namespaced, in the form a prompt can quote: github_tools.NOT_FOUND
messageReports it to the user, or reasons from it alone when a framework strips the restSelf-sufficient: the upstream detail embedded, the next move implied
whyDecides whether to retry, change input, or stopNames the real cause, including the one that looks like something else
fixExecutes itAn imperative with the exact command, env var, or parameter. No "check your configuration"
linkFetches it when why and fix are not enoughA stable anchor to the section that covers this case, not the docs home

The two github_tools entries below are the ones models got wrong most often before the catalog existed, and they show what "names the real cause" means in practice:

src/core/errors.ts
NOT_FOUND: {
  status: 404,
  message: ({ detail }: { detail: string }) => `GitHub resource not found (404): ${detail}`,
  why: 'Either the resource does not exist, or the token cannot see it. GitHub deliberately returns 404 instead of 403 for private resources the token has no access to.',
  fix: 'Check the owner/repo/number input first. If it is correct, the token lacks access: grant the repository to the PAT or App installation, or use a Connect subject that has access.',
  link: 'https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#404-not-found-for-an-existing-resource',
},
OIDC_TOKEN_EXPIRED: {
  status: 401,
  message: ({ expiredAt }: { expiredAt: string }) =>
    `VERCEL_OIDC_TOKEN expired at ${expiredAt}; Vercel Connect would reject it, so no GitHub request was made.`,
  why: 'The SDK pins the VERCEL_OIDC_TOKEN environment token when it is set, so an expired env token is never refreshed automatically.',
  fix: 'Locally: run `vercel env pull` to refresh .env.local. On Vercel: deployments inject a fresh token; check nothing overrides VERCEL_OIDC_TOKEN with a stale value.',
  link: 'https://github-tools.com/guide/vercel-connect#eve-agent',
},

NOT_FOUND tells the model the thing a GitHub 404 hides: the resource may exist and the token cannot see it. Without that sentence, a model concludes the repository is gone and stops. OIDC_TOKEN_EXPIRED is thrown before any request is made, with the expiry timestamp, so the model never sees the opaque Connect 403 that used to arrive instead. Both fix strings name the command to run.

Write the four sentences

The test for each field is whether a model, holding only the error, can act correctly on the first try.

why names the cause the symptom hides. State what happened at the layer that knows: the token is invalid, the OIDC token expired, the input was well-formed and the provider refused it. Where the symptom misleads (a 404 that is really a 403, a 403 that is really a rate limit), say so in the second sentence. That sentence is the one that saves the loop.

fix is one imperative the model can run. Start with the verb. Name the exact parameter, environment variable, or command: set GITHUB_TOKEN, run vercel env pull, retry after the reset timestamp in the message. If the fix depends on where the code runs, split it by environment the way OIDC_TOKEN_EXPIRED does. If the right move is to stop, say stop: Stop calling this tool and retry after the reset timestamp prevents a model from burning its quota on retries.

message stands alone. Some frameworks forward only error.message to the model. Embed the upstream detail with a typed template (({ detail }) => ...) so the message carries the provider's own words, and write it so the next move is implied even when why and fix are stripped.

link goes to the section, not the site. A model that fetches the link reads the page it lands on. https://github-tools.com/guide/vercel-connect#eve-agent costs one fetch; a docs home page costs a search. External docs are fine when they are the authority, and GitHub's own troubleshooting anchor is the right link for a GitHub 422.

What this costs: the four sentences go stale when the upstream API changes, and a wrong fix misleads a model faster than no fix at all, because the model trusts it. Review the catalog when you bump the upstream SDK, and keep Exporting the Catalog in the build so the docs regenerate from the same source.

Return the structure to the model

The catalog does nothing for an agent if the tool layer flattens the error to a string before the model sees it. Where your package owns the tool boundary, return the projection instead of throwing:

src/eve/steps.ts
import { EvlogError } from 'evlog'

function toModelErrorPayload(error: EvlogError): Record<string, string> {
  return {
    ...(error.code ? { code: error.code } : {}),
    message: error.message,
    ...(error.why ? { why: error.why } : {}),
    ...(error.fix ? { fix: error.fix } : {}),
    ...(error.link ? { link: error.link } : {}),
  }
}

export async function runTool(name: string, input: Record<string, unknown>) {
  try {
    return await execute(name, input)
  } catch (error) {
    if (EvlogError.isEvlogError(error)) {
      return { error: toModelErrorPayload(error) }
    }
    return { error: error instanceof Error ? error.message : String(error) }
  }
}

internal and status stay out of the payload: the request URL is for the host's logs, and the HTTP status means nothing to a model deciding what to do next. This is what @github-tools/sdk returns from its eve tools, and it is the shape that lets the model read fix and act on it in the same turn.

Where the host owns the boundary, tell it what to do in your README. With the AI SDK, generateText and streamText forward error.message from a throwing tool, which is why the message has to stand alone; a host that wants the full structure in an error boundary or an onError handler calls parseError(error) and gets code, why, fix, and link as flat fields.

Where the catalog reaches the model before the error does

An error carries its entry to the model at failure time. The exported catalog reaches it earlier: in your llms.txt, in an agent skill, or in a JSON file the agent loads at the start of a session. An agent that has read github_tools.RATE_LIMITED before its first call writes the backoff before it needs it. Exporting the Catalog generates those artifacts from the same defineErrorCatalog call, so the version the agent read matches the version your package throws.