agentio reports every expected failure with a sentinel error from the root package. Vendor packages wrap them with their own wording (fmt.Errorf("claude: …: %w", agentio.ErrExpired)), so the message text changes between packages but the sentinel does not: branch with errors.Is, never on the message. You want this page to turn a library error into the right advice for your user, and to handle a rate limit without telling them to log in again.
- Module:
github.com/ubgo/agentio; the example also usescodexfor an agent ID. - Nothing on disk.
Every sentinel answers one question. The list:
| Sentinel | Means | Tell the user |
|---|---|---|
ErrNotInstalled |
the binary is not on PATH | install the CLI |
ErrNotLoggedIn |
the home holds no usable credential | log in with the vendor CLI |
ErrExpired |
the local credential is past its expiry (never refreshed by the library) | run the vendor CLI once |
ErrUnauthorized |
the server refused a credential that looked valid | log in again |
ErrScopeLimited |
the credential can run the agent but cannot read account, plan or usage | not a login problem |
ErrBilledPerToken |
no usage windows exist: an answer, not a failure | show "billed per token" |
ErrRateLimited |
a transient 429 (Step 2) | try again later |
ErrUnsupported |
the agent lacks the capability or launch field; always wrapped with what and why | show the reason |
ErrForeignHome |
a home of another agent was passed | a bug in the caller |
ErrUnknownVersion |
a vendor file declares a format version the library has no sample for; only that reader fails | upgrade agentio |
ErrFeatureUnavailable |
the installed vendor version provably lacks a flag or file | upgrade the CLI |
ErrMoveBlocked |
a move plan has blockers | show the blockers |
ErrUnknownAgent |
an ID lookup failed | check the agent ID |
ErrNotFound |
a named item (conversation, session, file) does not exist | — |
ErrDangerousNotAllowed |
full autonomy without Target.AllowDangerous |
a policy answer |
ErrInvalidSpec, ErrInvalidID |
a config-defined agent or ID was rejected | fix the config |
One function can map them all:
// explain maps an agentio error to what a caller tells the user. Branch
// with errors.Is: vendor packages wrap the sentinels with their own
// wording, so the message text is not stable but the sentinel is.
func explain(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, agentio.ErrNotInstalled):
return "install the CLI first"
case errors.Is(err, agentio.ErrNotLoggedIn):
return "log in with the vendor CLI"
case errors.Is(err, agentio.ErrExpired):
return "run the vendor CLI once to refresh its login"
case errors.Is(err, agentio.ErrScopeLimited):
return "this credential can run the agent but not read usage (not a login problem)"
case errors.Is(err, agentio.ErrBilledPerToken):
return "billed per token: there are no usage windows"
case errors.Is(err, agentio.ErrRateLimited):
return "rate-limited: try again later"
case errors.Is(err, agentio.ErrUnsupported):
return "not supported by this agent"
case errors.Is(err, agentio.ErrInvalidSpec), errors.Is(err, agentio.ErrInvalidID):
return "fix the agent config"
case errors.Is(err, agentio.ErrNotFound):
return "no such item"
}
return "unexpected: " + err.Error()
}
// matchSentinels produces real errors from the library and explains each.
func matchSentinels(p printer) error {
_, notInstalled := agentio.LookPath("", "claude")
_, badID := agentio.ParseID("My Agent")
_, badSpec := agentio.NewCore(agentio.Spec{ID: "toy"}, agentio.Host{})
// A config agent whose Spec has no PromptArgs refuses a prompt with
// ErrUnsupported instead of dropping it.
toy, err := agentio.FromSpec(agentio.Spec{ID: "toy", Binary: "toy", NewArgs: []string{"{args}"}}, agentio.Host{})
if err != nil {
return err
}
_, unsupported := toy.Launch(toy.DefaultHome().Target(), agentio.LaunchRequest{Prompt: "hi"})
byHand := agentio.UnsupportedError(codex.ID, "history move", "conversations are indexed in SQLite")
expired := fmt.Errorf("claude: credentials: %w", agentio.ErrExpired)
for _, err := range []error{notInstalled, badID, badSpec, unsupported, byHand, expired} {
p.line(fmt.Sprintf("error: %v\n -> %s", err, explain(err)))
}
return nil
}agentio.UnsupportedError(agent, what, reason) builds the ErrUnsupported error every agent returns, so you can show the agent's own reason ("conversations are indexed in SQLite") instead of a generic message.
A 429 from a vendor usage or profile endpoint is ErrRateLimited, never a login error. agentio.RateLimitError(header, now) is how every vendor client words it: it reads Retry-After (seconds, or an HTTP date relative to now, which is Host.Now()) and adds the wait to the message. agentio.RetryAfter(value, now) parses the header on its own.
// rateLimits words a 429 the way every vendor client does: RateLimitError
// reads Retry-After (seconds or an HTTP date relative to now) and wraps
// ErrRateLimited, never a login error. Pure.
func rateLimits(p printer) error {
now := time.Date(2026, 11, 14, 12, 0, 0, 0, time.UTC)
for _, retry := range []string{"120", now.Add(90 * time.Second).Format(http.TimeFormat), ""} {
h := http.Header{}
if retry != "" {
h.Set("Retry-After", retry)
}
err := agentio.RateLimitError(h, now)
p.line(fmt.Sprintf("429 retry-after=%q: %v (rate-limited=%t)", retry, err, errors.Is(err, agentio.ErrRateLimited)))
}
if d, ok := agentio.RetryAfter("30", now); ok {
p.line(fmt.Sprintf("RetryAfter(30) = %s", d))
}
return nil
}cd examples && go run ./valuesThe lines for this page:
error: agentio: binary not found on PATH: claude
-> install the CLI first
error: agentio: invalid agent id: "My Agent": only [a-z0-9-] allowed
-> fix the agent config
error: agentio: invalid spec: toy: binary is empty
-> fix the agent config
error: agentio: unsupported: toy: prompt: not described by the agent spec
-> not supported by this agent
error: agentio: unsupported: codex: history move: conversations are indexed in SQLite
-> not supported by this agent
error: claude: credentials: agentio: login expired
-> run the vendor CLI once to refresh its login
429 retry-after="120": agentio: rate-limited; try again shortly (retry after 2m0s) (rate-limited=true)
429 retry-after="Sat, 14 Nov 2026 12:01:30 GMT": agentio: rate-limited; try again shortly (retry after 1m30s) (rate-limited=true)
429 retry-after="": agentio: rate-limited; try again shortly (rate-limited=true)
RetryAfter(30) = 30s
- Match with
errors.Is, never with==or the message. Every vendor error wraps the sentinel;err == agentio.ErrExpiredis false forclaude: credentials: agentio: login expired. ErrScopeLimitedis not a login problem. A setup-token can run Claude Code but cannot read the account or usage; telling the user to log in again does not help.ErrBilledPerTokenis an answer. An API-key home has no usage windows; show that instead of an error.ErrExpiredis never fixed by the library. It never refreshes an OAuth token, because refreshing rotates the refresh token and logs the vendor CLI out.- Do not poll after a 429. Usage endpoints rate-limit hard; cache the last answer and wait at least the
Retry-Afterhint. ARetry-Afterdate in the past, a negative number or garbage gives no hint at all (RetryAfterreturns false). ErrDangerousNotAllowedis notErrUnsupported. Refusing full autonomy is a policy answer; the agent does support it onceTarget.AllowDangerousis set.- Vendor packages add their own sentinels for vendor-only cases (for example
claude.ErrHistoryExists,claude.ErrAmbiguousID); they are documented on the vendor pages.