Error contract
An agent reads these before any person does. So every error comes with a stable code to branch on and a hint at what to do next.
One shape, whatever broke — a wrong path, an oversized body, a timeout, or a bug of our own.
{
"error": {
"code": "approval_required",
"message": "destroying project radar and everything in it needs human approval",
"fix_hint": "we emailed you@example.com — ask them to open it, sign in, and approve (code F25T-YNG5). Then run the same command again. Expires in 15 minutes."
}
}Branch on the code, never on the prose. We reserve the right to reword the prose any time it reads badly; the code stays put, or it was never worth anything.
Every code
| Code | What happened | What to do about it |
|---|---|---|
| unauthorized | This machine has no credentials it can use | gg login, give your human the link and code it prints, and run gg login again once they have approved. Do not ask your human for a token |
| authorization_pending | Nobody has approved the gg login request yet | Pass on the link and code again if your human does not have them. Run gg login again once they say they approved — not in a loop |
| expired_token | The gg login request lived fifteen minutes and nobody approved it | gg login for a fresh link, and pass it on straight away |
| access_denied | Your human declined the gg login request | Ask them why before running it again. Do not retry on your own |
| insufficient_scope | These credentials do not carry that right | A browser session cannot deploy. Deploy from the CLI, with machine credentials |
| approval_required | Somebody has to say yes to this first | A deletion, a released address, or a withdrawn dependency. We email your human a button; they sign in to the console and approve. Give them the code from the hint and wait. Do not poll — and one approval covers the fifteen minutes after it |
| approve_in_console | Approvals are made by a person signed in to the console | An agent credential cannot approve, not even the account’s own. Your human opens the email and approves there |
| not_your_approval | That approval was sent to another account | The hint names the address. Your human signs out of the console and signs in as that account |
| approval_not_found | No approval matches that link | It expired or was withdrawn. Run the command that asked for it again, for a fresh email |
| no_console | This gagarin has no console to approve in | Not your fault. It is an operator setting; tell your human |
| owner_only | Only the project owner can do that | Go and ask the owner. Nobody can grant you this, so retrying is wasted |
| insufficient_role | You are a viewer — read only | Ask the owner or an editor to give you write access |
| project_not_found | No such project, or none you can see | gg projects lists what you can reach; gg init PROJECT starts a new one |
| project_exists | You already have a project by that name | Names are unique inside an account. Pick another, or deploy into the one you have |
| invalid_name | That name will not do | 2–30 characters: lowercase letters, digits and hyphens, starting with a letter |
| invalid_role | The only roles are editor and viewer | owner is not something you hand out — it is whoever pays |
| owner_not_a_member | That address already owns the project | Nothing to do. Their access is as complete as it gets |
| member_not_found | That address has no access | gg members PROJECT shows who does |
| image_required | You did not say which image | gg build then gg push — or gg ship, which does both |
| image_not_yours | That image belongs to another project | It has to be in this project's registry. gg build and gg push put it there |
| invalid_digest | That does not look like a sha256 | Pass what docker push handed back, or pass nothing at all |
| invalid_port | Port out of range | Give the port the container actually listens on |
| invalid_kind | That is not a kind of thing gagarin runs | A service, or a job. kind: job is how gg run says “this one ends”; nothing else exists |
| job_has_no_port | A job listens on nothing | Drop the port. If it serves, it is a service, not a job |
| job_has_no_volume | A job keeps nothing between runs | Drop the volume. Durable data belongs in a resource the job reaches with --deps |
| not_a_job | That name is a service, and a name is one kind forever | Give the job a name of its own. gg status PROJECT shows which is which |
| invalid_volume | That volume path will not do | An absolute path inside the container, e.g. /var/lib/postgresql/data |
| volume_immutable | A volume is set once and never moves | Leave it where it is, or destroy and recreate — which throws the data away |
| invalid_domain | That is not a hostname | Give a name you own, e.g. shop.example.com. No scheme, no port, no path |
| domain_taken | Another service already holds that name | Pick another, or release it wherever it is declared. We will not say who has it — that is a fact about somebody else's account |
| domain_attached | Your own names are still declared on this service | Take each one off first: gg domain rm PROJECT/SERVICE shop.example.com. Otherwise your DNS keeps pointing here after we stop answering for the name |
| invalid_needs | That dependency list does not parse | Name services in this project, e.g. gg deps add PROJECT/api db |
| invalid_deps | The same, on the --deps of a deploy or a run | Name things in this project. A job cannot be named as something to reach — it listens on nothing. --deps only ever adds; gg deps rm is how one is withdrawn |
| invalid_size | No such size | s, m or l — or leave it out to keep the one it has |
| service_in_use | Something still depends on this | Cut the edge first: gg deps rm PROJECT/api db, which asks for its own approval — though the same click covers the destroy that follows. The refusal names what is depending on it |
| no_such_service | This project has no service by that name | Deploy it first. A deleted service takes its history with it |
| no_such_revision | The service never had that revision | gg history PROJECT/SERVICE lists the ones it did |
| nothing_to_roll_back_to | There is nowhere to roll back to | The service has only ever been deployed once. This is not a bad call |
| unknown_resource_type | No such resource type | The hint lists the ones that exist: postgres, qdrant, valkey, external |
| wrong_resource_type | Another type of resource already has that name | Destroy it and create the one you meant — which throws its data away |
| invalid_storage | Storage size out of range | 1 to 100 GB, set once, at creation |
| no_storage | This type has no volume to size | A valkey keeps everything in memory; an external runs nothing at all |
| no_size | An external has no container to size | Drop --size. It is a set of values, not something we run |
| no_env | This type mints its own credentials | Drop the values and let us pick — a password you chose is one the running server has never heard of. gg resource secrets reads what we minted |
| invalid_env | One of those keys will not do | Every variable is published under the resource's own name, so write API_KEY rather than OPENAI_API_KEY — the doubled name is why this is refused |
| env_required | An external's values are yours to supply | gg resource rotate PROJECT/NAME --set KEY=value to change one of them, or --env-file <file> to replace all of them |
| conflicting_env | A rotation either replaces the values or amends them | --env and --env-file say the bundle is now precisely this; --set and --unset change part of it and leave the rest. Send one or the other |
| no_such_key | That external does not publish the key you asked to remove | gg resource secrets lists what it does publish. Name the key without the resource prefix — BASE_URL, not OPENAI_BASE_URL |
| already_exists | That external is already there | Changing what it publishes is its own call: gg resource rotate. Restating it from an old file would roll the key backwards, which is why this is refused rather than allowed |
| cannot_rotate | There is nothing recorded to replace | gg resource secrets shows what it holds. Worth reporting — this should not happen |
| rotate_failed | We could not tell the server its new password | Nothing changed: the old credential is still in use, and this is safe to run again. gg status will say if the resource is not running |
| no_such_resource | This project has no resource by that name | gg status PROJECT lists what it has |
| not_tunnelable | An external has nothing to tunnel to | Nothing runs on our side of an external — it is a set of values, and gg resource secrets reads them |
| tunnel_unavailable | No running pod on the far end of the tunnel | gg status PROJECT says whether the resource is running and why not, then gg connect again |
| cannot_delegate | A minted credential cannot mint another | Mint it from the machine a human approved. A token that could issue its own replacements would make revoking a leaked one pointless |
| name_required | A credential needs a name | Name it after where it will live — it is what you read in gg credentials months later |
| name_too_long | That name is longer than anyone will read | 120 characters is plenty for something that appears in a list |
| invalid_expiry | That expiry is out of range | 1 to 365 days, or leave it out for 90. There is no never |
| not_a_resource | That is a service or a job, not a resource | Use the /services/ path — a job is deleted and deployed down it too. gg status PROJECT shows which is which |
| not_a_service | That is a resource or a job, not a service | You cannot deploy a service over either, or give either a domain. A resource you reach by name from inside the project; a job you re-run with gg run |
| no_such_resource | No resource by that name | gg status PROJECT lists everything the project has |
| backup_unsupported | This resource type keeps no backups | Only postgres is backed up. A cache is a cache; if the data matters it belongs in postgres |
| backup_unconfigured | This gagarin runs without a backup bucket | Not your fault. Tell your human |
| restore_target_not_empty | The restore target already holds data | A restore only fills a new, empty resource: gg resource restore PROJECT/new-name --source old-name. Nothing here overwrites a live database |
| backup_mismatch | That backup key belongs to a different project | gg resource backups PROJECT/NAME lists this one’s |
| no_backups | Nothing stored for that source yet | The nightly pass takes the first one, or take one now: gg resource backup PROJECT/NAME |
| backup_failed | The dump itself failed | The resource must be running. gg status PROJECT, then retry once |
| backup_list_failed | The bucket could not be listed | Try once more. If it happens again, tell your human |
| restore_failed | The restore itself failed | The new resource must be running first — restore does not resurrect a destroyed one. gg status PROJECT, then retry |
| apply_failed | State was saved; the cluster would not take it | Run exactly the same call again. It is idempotent |
| cluster_error | We could not reach the infrastructure | Not your fault, and not something you can fix. Tell your human |
| logs_unavailable | There is no pod yet | gg status PROJECT first |
| no_such_route | Wrong path or wrong method | Check the skill. Do not invent endpoints |
| body_too_large | That request body is over the limit | Big values belong in secrets, not in a deploy call |
| timeout | We gave up waiting on the infrastructure | Try once more. If it happens again, tell your human |
| rate_limited | Too often | Wait a minute. Never retry in a tight loop |
| internal_error | A bug in gagarin | Tell us. The details are in the platform's log, not in yours |
| invalid_email | The address given to gg share or gg transfer does not parse | Ask your human for it again rather than guessing |
| email_failed | The approval email did not leave gagarin | Nothing was asked for. Try once more in a minute, then tell your human — nothing is waiting in an inbox to be found |
| email_capped | That address has had twenty approval emails in a day | Have your human approve the requests already in their inbox, or wait a few hours |
| eject_failed | The export would not assemble | That is a bug in gagarin, not in your call. Tell us |
Three refusals that get mixed up
approval_required means go and get your human, have them sign in and press a button, then run the same thing again — it will work the second time. owner_only means go and ask whoever pays the bill; there is no approval that opens that door for you. project_suspended means neither of those. Waiting will not help, retrying will not help, and the message says why: a balance that ran out, which the owner can top up, or an abuse finding, which needs a conversation with support. Three codes, because what happens next is three different moves.