Drive grr like a terminal tool, not an SDK.
grr is built to be called by something that is not a person: results on stdout, logs on stderr, JSON by default, and a discovery surface so you never have to guess whether a method exists. The same contract ships as a packaged agent skill.
The whole contract in one script.
Copy this into a harness bootstrap, or read the four rules under it. Everything here is copy-pasteable and every flag in it exists in the binary.
#!/usr/bin/env bash
# grr agent quickstart — the four rules that matter, in one script.
set -euo pipefail
# 1. Is there a usable credential? Exit status is the signal.
if ! grr auth status >/tmp/grr-auth.json 2>/tmp/grr-auth.err; then
echo "not authenticated: $(cat /tmp/grr-auth.err)" >&2
exit 1
fi
EMAIL=$(jq -r .email /tmp/grr-auth.json)
# 2. Discover, do not assume. Both work with no network and no login.
grr schema | jq -r '.subcommands[].name'
grr api list --service gmail --filter "messages" \
| jq -r '.[].id'
# 3. Inspect a write before performing it. Sends nothing, prints JSON.
grr calendar events insert --calendar-id primary \
--params '{"summary":"Standup","start":{"dateTime":"2026-10-05T09:30:00Z"},"end":{"dateTime":"2026-10-05T09:45:00Z"}}' \
--dry-run | jq -e '.url' >/dev/null
# 4. Parse stdout only. stderr carries logs and human text.
grr gmail users messages list --user-id me --q "from:github.com" --max-results 10 2>/dev/null \
| jq -r '.[] | "\(.id)\t\(.snippet)"' Parse stdout. Never stderr.
This is the single most important thing to get right, and grr is explicit about it in the code rather than leaving it to convention.
The tracing subscriber is wired to stderr at startup, deliberately, so grr … | jq cannot receive a log line. Data goes to stdout and only data goes to stdout.
Two commands annotate that contract in ways an agent should know about:
$ grr gmail users messages list --user-id me --max-results 1
# stdout: [{"id":"19f8ab2c","threadId":"19f8ab2c","snippet":"Build notes ..."}]
# stderr: INFO grr_cli::core::auth: Loaded token from OS keyring
$ grr api list 2>&1 >/dev/null
308 methods # the count is deliberately on stderr
$ grr auth login --device
Visit https://google.com/device and enter code: ABCD-EFGH
# ^^ that line is plain text on STDOUT, before the JSON result
$ grr transport
# plain text, not JSON. Do not pipe this into a parser. grr api listprints its method count to stderr and the array to stdout. That is whygrr api list | jqis valid JSON.grr auth login --deviceprints its human-readable URL and code to stdout with a plainprintln!, before the JSON result. If you are parsing the result of a device login, do not assume stdout is JSON for that one command.grr transportprints a plain-text report, not JSON. It is a diagnostic for a human; read it, do not parse it.- Every
--dry-runoutput is JSON, on either route (generated leaf orgrr api call), so it pipes and ajqassertion works on it. - Suppress logs with
2>/dev/nullwhen you want quiet failures, but keep them when you are debugging — the error text is where the useful detail is.
Errors are not JSON either. A failed command exits non-zero with a human-readable message on stderr, and that message is written to be actionable: a 403 names the missing scope, a missing path parameter names itself, an unknown method id comes with ranked suggestions.
Ask for JSON explicitly if you like being explicit
JSON is already the default, but -f json is harmless and documents intent at the call site. The four values are json, jsonl, table, and pretty.
Prefer -f json over -f table or -f pretty in anything automated. table is for humans, and its output is a rendered grid whose shape changes with the data. jsonl is the one to reach for when streaming a large list: one document per line, so you can process results incrementally instead of buffering the whole array.
There is no global --format flag, and that is not an oversight. A global flag would collide by id with per-method parameters named format — which is exactly why such parameters surface as --param-format.
Discover capabilities. Do not assume them.
The single biggest failure mode for an agent driving a Google CLI is confidently calling a method that does not exist, or a parameter that was renamed. Both are avoidable, and cheaply.
The discipline is fixed order: grr schema for the full command tree, grr api list to browse methods, grr api describe for one method’s parameters and scopes, --dry-run before the call. All four work with no network and no credential — the Discovery index is compiled into the binary — so an agent can answer “what can I do with Sheets?” before it has a login, which is exactly the order you want to fail in.
grr schema is the whole tree as JSON — every subcommand, every argument, whether it is required, its default, its possible values. Because the tree itself is generated from the Discovery index, schema is a faithful census of all 308 methods, and it answers with zero configuration. The naming rule means you rarely need it twice: a method id, dots turned into spaces, is the command (gmail.users.messages.list → grr gmail users messages list), and parameter names become kebab-case flags (userId → --user-id).
# What can I do? No network, no credential needed.
$ grr api list --grouped | jq -r '.[] | "\(.service)\t\(.count)"'
# Narrow it. --filter matches ids AND descriptions, case-insensitively.
$ grr api list --filter spreadsheet | jq -r '.[].id'
# What does this one need? Params, required flags, scopes, verb, path.
$ grr api describe sheets.spreadsheets.values.update | jq '{
http: .httpMethod,
path: .path,
required: [.parameters[] | select(.required) | .name],
scope: .leastPrivilegeScope
}'
# The whole generated command tree, as a machine-readable contract.
$ grr schema | jq -r '.subcommands[].name' Two habits worth building in:
- Read
describebefore you construct a call. Theparametersarray tells you which arerequired, which arerepeated, and which haveenumvalues. Guessing an enum string is the most common cause of a 400. - Treat the method count as volatile. 308 methods is what the embedded index holds today; the index is refreshed daily from Google’s Discovery Service and the tree regenerates with it, so a method can appear or disappear between runs. Prefer
--filterover a hard-coded list where you can, and handle the “unknown method” error as a normal outcome rather than a crash.
Inspect a request before you send it.
One dry-run surface, two routes into it — and both print JSON.
--dry-run exists on every generated leaf and on grr api call. It builds the request, validates the required parameters, and prints the verb, the fully resolved URL, the body, and the scopes — then sends nothing. URL construction and parameter validation both happen before the dry-run branch, so a dry run surfaces exactly the errors the real call would.
That makes the pattern “dry run, inspect the URL, then send” a cheap guard against the two failure modes an agent actually hits: a path parameter in the wrong place, and a body field Google rejects. A jq -e '.url' assertion on the dry-run output is a reasonable gate before a destructive call.
# Every method — leaf or api call — has --dry-run. JSON out, nothing sent.
$ grr gmail users messages delete --user-id me --id 191f8ab2 --dry-run
{"method":"DELETE","url":"https://gmail.googleapis.com/gmail/v1/users/me/messages/191f8ab2",...}
# Prefer the reversible operation where the API offers one.
$ grr gmail users messages trash --user-id me --id 191f8ab2
# Bulk effect? Resolve the ids with a read first, show them, then act
# on the explicit list — never on a query you have not reviewed.
$ grr gmail users messages list --user-id me --q "from:newsletter.example" \
--max-results 100 | jq -r '.[].id' Note the asymmetry. messages trash is reversible; messages delete is not, and Gmail’s API has no “really delete” for the batch endpoints either. For anything destructive or bulk, resolve the ids first with a read, show them to the user, and only then act on the explicit list.
Ask for the least privilege the method needs.
grr consents to sixteen scopes. The narrowest scope each method accepts is reported, and a mismatch is named rather than swallowed.
OAuth consent is granted once, for a fixed set of scopes, at grr auth login. You cannot widen it later from inside an agent run — that needs a new consent, which needs a human. So scope is a design constraint, not a runtime knob.
Because every command resolves its method against the index, the scope check is the same on both routes: read leastPrivilegeScope from grr api describe before relying on a method, and treat the result as part of your plan, not an afterthought.
Two behaviours to expect, both benign, both worth not misdiagnosing:
- A note on stderr saying a method wants a scope outside the consented set is a warning, not a verdict. grr attempts the call anyway, because the narrow scope is often a sibling of a broader one the build does hold. Check the exit status, not the note.
- A 403 is the real answer. grr’s error names the specific scope and the file to edit. The common cause is a method genuinely outside the consented set — Gmail CSE keypairs, S/MIME settings, directory people, most of the Chat admin surface, and all of Tasks, which needs
…/auth/tasks.
The full breakdown, including which services work and which do not, is on Discovery API.
You will never see a token, and you should not store one.
There is no flag that prints an access token. The credential lives in the OS keyring and grr reads it itself.
Tokens are stored in the operating system keyring — Windows Credential Manager, macOS Keychain, or the Linux Secret Service over D-Bus — under the service grr. On a headless machine with no keyring daemon, grr falls back to a token.json file under the user cache directory and imports it back into the keyring the next time one is available.
What an agent can observe is only the backend name, and only on login:
grr auth loginprintstoken_backend(os-keyringorfile) and a 20-charactertoken_previewprefix. The preview exists so a human can confirm which credential was stored; it is not a usable token.- Refresh is automatic and transparent. A stored credential that has expired and has no refresh token errors with “rerun
grr auth login” rather than silently opening a browser mid-call. - PKCE is always on, so there is no client secret in the request that matters, and nothing for an agent to capture from a network trace.
Do not attempt to read the token out of the keyring, and do not add a flag to expose it. If a workflow needs a token for something other than grr, that something should have its own OAuth client. grr auth setup writes a client to ~/.grr/config.toml (mode 0600 on unix) if you need to manage one non-interactively, and it refuses to hang on a non-TTY: pass --client-id and --client-secret, or it errors instead of prompting.
Check auth before you check anything else.
One cheap call, a real answer, and a history_id cursor worth keeping.
$ grr auth status
{"authenticated":true,"email":"you@example.com","messages_total":48213,
"threads_total":9120,"history_id":"1280000"}
# Non-zero exit when there is no valid credential. Check this first in
# any automation: it is one cheap call and it fails loudly.
$ grr auth status >/dev/null || grr auth login --device
# history_id is the cursor for incremental reads.
$ grr gmail users history list --user-id me \
--start-history-id 1280000 --max-results 100 | jq -r '.[].id' grr auth status makes a live Gmail users.getProfile request through the same shared call path as every other command, so it fails when the token is missing, revoked, or expired without a refresh token. Gate any automation on its exit status. grr auth login is interactive by default; --device is the only variant that works unattended, and it still needs a human to visit a URL and type a code.
For incremental reads, keep the history_id that auth status returns and feed it to grr gmail users history list --start-history-id on the next run. That is the difference between fetching a bounded window and re-reading the mailbox.
Failure modes worth handling explicitly
- Not authenticated. Exit non-zero, message names the rerun. Do not retry in a loop.
- Expired without a refresh token. The error says to rerun
grr auth login. This happens if a token was stored without a refresh token; it is not transient. - HTTP 403 naming a scope. The scope is outside what the build consented to. Not fixable at runtime — report it rather than retrying.
- Unknown method id. grr returns ranked suggestions. Treat it as “discover again”, not as a crash: the index is refreshed daily and the surface moves.
- Missing required parameter. The message names the parameter and lists the others. Re-read
grr api describerather than retrying variations. - Empty results. A valid empty array, not an error.
-f tablerenders it as a(no results)header; the JSON form is just[]. - HTTP/2 fallback.
grr transportshowingfell_back: truemeans QUIC was blocked and HTTP/2 is in use. Nothing is wrong; do not retry or treat it as a fault.
Mapping this onto a harness
Claude Code, Codex, Cursor, Gemini CLI, OpenClaw, and anything else that shells out can be set up from the same sources, in this order:
- skills/grr/SKILL.md ↗ — the packaged agent skill: the discovery-first discipline, the naming rule, and the output contract in one file. Install it with
npx skills add https://github.com/debanjanbasu/grr-cli. - llms.txt — the site’s machine-readable index, for orientation. Fetch it once at session start.
grr schema— the generated command tree, for building tool definitions. Regenerate it rather than hand-writing flags.grr api listandgrr api describe— the method-level view of the same index, at runtime, with no network and no credential.
The quickstart at the top of this page is the portable form of the same guidance — paste it into any harness’s system prompt or bootstrap script, or install the skill and let the harness load it.