Describe the bug
dhq projects create on an account that has used its plan's projects exits 3 (auth_error) with "auth failed", although nothing is wrong with the credential. The API answers precisely:
HTTP 403
{"error": "project_limit_reached", "message": "You have no projects remaining. Please delete a project or upgrade to a larger paid plan."}
The CLI maps every 401 and 403 to auth_error (internal/output/breadcrumbs.go:120), with a single hand-picked exception for email_verification_required (pkg/sdk/errors.go:56). The coded reason and the message are thrown away, so a user, and any agent reading the exit code, is told to fix credentials for a plan limit. Found by the deploy validator on 2026-09-24 (run wwvzkvxz9ivx, recheck leg).
Choose a priority
medium: wrong diagnosis on a common situation, easy workaround once you know.
Choose a specialism
backend
What behaviour is expected?
A 403 whose body carries an error code that is not an authorisation code is a user_error (exit 1) and the API's message is shown. Only a 403 with no code, or with an authorisation code, stays auth_error. This generalises the existing email-verification case instead of adding another special case. Same for the SDK's IsForbidden callers, so the agent metadata and skill docs describe the exit codes truthfully.
Additional context
The API's 403 is defensible (the plan forbids the action) and clients match on it, so the status should stay; the fix is on the CLI side.
Describe the bug
dhq projects createon an account that has used its plan's projects exits 3 (auth_error) with "auth failed", although nothing is wrong with the credential. The API answers precisely:The CLI maps every 401 and 403 to
auth_error(internal/output/breadcrumbs.go:120), with a single hand-picked exception foremail_verification_required(pkg/sdk/errors.go:56). The coded reason and the message are thrown away, so a user, and any agent reading the exit code, is told to fix credentials for a plan limit. Found by the deploy validator on 2026-09-24 (runwwvzkvxz9ivx, recheck leg).Choose a priority
medium: wrong diagnosis on a common situation, easy workaround once you know.Choose a specialism
backendWhat behaviour is expected?
A 403 whose body carries an
errorcode that is not an authorisation code is auser_error(exit 1) and the API'smessageis shown. Only a 403 with no code, or with an authorisation code, staysauth_error. This generalises the existing email-verification case instead of adding another special case. Same for the SDK'sIsForbiddencallers, so the agent metadata and skill docs describe the exit codes truthfully.Additional context
The API's 403 is defensible (the plan forbids the action) and clients match on it, so the status should stay; the fix is on the CLI side.