I wanted Claude Code to look at my Google Search Console data and tell me what to write next, without me exporting CSVs. The Search Console API can do that, but the setup crosses three places: Google Cloud for the credentials, Search Console for the permission, and the Claude Code skill that actually calls the API.
This is the whole path, from an empty Google Cloud project to a skill that Claude runs on its own. It's the same setup behind the content-scout skill in this blog's repo.
Quick answer
- Create a Google Cloud project and enable the Google Search Console API.
- Create a service account and download its JSON key.
- In Search Console, add the service account email as a Restricted user on your property.
- Write a small script that authenticates with the key and calls
searchAnalytics/query. - Put the script next to a
SKILL.mdin.claude/skills/<name>/so Claude Code knows when and how to run it.
No OAuth consent screen and no browser login. The service account is just another user on your Search Console property.
Why a service account and not OAuth
The Search Console API supports both. OAuth means a browser consent flow and refresh tokens tied to your Google account, which is fine for a web app but awkward for a script that Claude runs in a terminal, or in a cloud session with no browser.
A service account is a robot Google account with its own email. You give it read-only access to one property, and it can't touch anything else you own. If the key leaks, you delete the key or remove the user in Search Console.
Step 1: Create a project and enable the Search Console API
In the Google Cloud console:
- Create a new project (I named mine after the site).
- Go to APIs & Services → Library, search for Google Search Console API, and click Enable.
Or with gcloud:
gcloud projects create henggana-gsc
gcloud services enable searchconsole.googleapis.com --project henggana-gsc
No billing account is needed for this API.
Step 2: Create a service account and a JSON key
In IAM & Admin → Service Accounts, create a service account. Skip the "grant this service account access to project" step. It doesn't need any Google Cloud role, because the access that matters is granted inside Search Console.
Then open the service account, go to Keys → Add key → Create new key → JSON. The browser downloads the key file.
With gcloud:
gcloud iam service-accounts create gsc-reader --project henggana-gsc
mkdir -p ~/.config/gsc
gcloud iam service-accounts keys create ~/.config/gsc/henggana-sa.json \
--iam-account gsc-reader@henggana-gsc.iam.gserviceaccount.com
I keep the key in ~/.config/gsc/, outside the repo. It's a long-lived credential, so it should never end up in git.
Copy the client_email from the key. You need it in the next step:
jq -r .client_email ~/.config/gsc/henggana-sa.json
Step 3: Add the service account to Search Console
In Search Console, pick your property, then Settings → Users and permissions → Add user.
- Email: the
client_emailfrom the key - Permission: Restricted
Restricted is enough for reading search analytics. The skill only reads data, so there's no reason to give it Full.
Note the exact property name, because the API wants it as siteUrl:
- Domain property:
sc-domain:henggana.com - URL-prefix property:
https://henggana.com/(with the trailing slash)
Step 4: Check the key can see the property
Before writing anything bigger, check that auth works and the permission landed. This lists every property the service account can see:
# /// script
# dependencies = ["google-auth", "requests"]
# ///
import os
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file(
os.path.expanduser("~/.config/gsc/henggana-sa.json"),
scopes=["https://www.googleapis.com/auth/webmasters.readonly"],
)
print(AuthorizedSession(creds).get("https://searchconsole.googleapis.com/webmasters/v3/sites").json())
The # /// script block is inline script metadata, so uv installs the dependencies on the fly:
uv run sites.py
You should see your property with siteRestrictedUser:
{'siteEntry': [{'siteUrl': 'sc-domain:henggana.com', 'permissionLevel': 'siteRestrictedUser'}]}
An empty {} means the key works but the service account isn't a user on any property yet. Go back to step 3.
Step 5: Write the script that queries search analytics
The useful endpoint is searchAnalytics/query. This is a trimmed version of the script in my skill. It pulls query + page rows for the last 90 days and prints them as TSV:
# /// script
# requires-python = ">=3.10"
# dependencies = ["google-auth", "requests"]
# ///
import datetime as dt
import os
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
SITE = "sc-domain:henggana.com"
KEY = os.path.expanduser("~/.config/gsc/henggana-sa.json")
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
creds = service_account.Credentials.from_service_account_file(KEY, scopes=SCOPES)
session = AuthorizedSession(creds)
end = dt.date.today() - dt.timedelta(days=3)
start = end - dt.timedelta(days=90)
rows, offset = [], 0
while True:
r = session.post(
f"https://searchconsole.googleapis.com/webmasters/v3/sites/{SITE}/searchAnalytics/query",
json={
"startDate": start.isoformat(),
"endDate": end.isoformat(),
"dimensions": ["query", "page"],
"rowLimit": 25000,
"startRow": offset,
},
)
r.raise_for_status()
batch = r.json().get("rows", [])
rows += batch
if len(batch) < 25000:
break
offset += 25000
print("query\tpage\tclicks\timpressions\tctr\tposition")
for row in sorted(rows, key=lambda r: -r["impressions"]):
q, page = row["keys"]
print(f"{q}\t{page}\t{row['clicks']}\t{row['impressions']}\t{row['ctr']:.1%}\t{row['position']:.1f}")
A few details in there:
endis three days ago. Search Console data lags a couple of days, and the most recent days come back empty or partial.rowLimitcaps at 25,000 per request, so the loop pages withstartRow.- The scope is
webmasters.readonly. Even if the permission were Full, the token can't write.
My real script goes one step further and only prints the rows worth acting on: queries at average position 8–20 (striking, page one is within reach) and queries in the top 8 with CTR under 2% (low-ctr, usually a title or description problem). A real run looks like this:
# sc-domain:henggana.com 2026-09-07..2026-10-05, 53 queries, 19 pages
query page clicks impressions ctr position bucket
resizeobserver is not defined /id/blog/fix-jest-resize-observer-is-not-defined/ 0 15 0.0% 8.2 striking
not implemented: window's scrollto() method /en/blog/fix-jest-error-window-scrollTo/ 0 13 0.0% 5.6 low-ctr
Filtering in the script keeps the output small. Claude reads fewer rows and spends its effort on deciding what they mean.
Step 6: Turn it into a Claude Code skill
A skill is a folder with a SKILL.md. Project skills live in .claude/skills/ in the repo, so they're versioned with the code and available in cloud sessions too:
.claude/skills/gsc/
SKILL.md
gsc.py
The description in the frontmatter is what Claude uses to decide when to load the skill, so put the phrases you'd actually type in it. The body tells Claude how to run the script and what to do with the output:
---
name: gsc
description: Use when the user asks about Search Console data, search queries, rankings, CTR, or "what should I write next" for henggana.com.
---
# Search Console
Run:
uv run -q .claude/skills/gsc/gsc.py
Key: `~/.config/gsc/henggana-sa.json`. If it's missing, say so and stop. Don't ask for the key in chat.
Output is TSV: query, page, clicks, impressions, ctr, position.
- Query ranks an existing post at position 8-20 → suggest what section or title change could push it to page one.
- Query in the top 8 with CTR under 2% → suggest a better title or meta description.
- Several queries with an intent no post covers → suggest a new post.
Traffic is small. Read the queries for intent, not volume.
That's it. Ask Claude Code "what's ranking on page two in Search Console?" and it loads the skill, runs the script, and reads the TSV. You can also call it directly with /gsc.
Without the instructions, Claude gets a pile of numbers and gives a generic SEO summary. With them it knows which rows mean "improve this post" and which mean "write a new one".
Step 7: Make it work in Claude Code cloud sessions
A cloud session doesn't have your ~/.config/gsc/ folder, so the key has to come from an environment variable. I made the script read GSC_KEY_JSON first and fall back to the file:
import base64
import json
if raw := os.environ.get("GSC_KEY_JSON", "").strip():
info = json.loads(raw if raw.startswith("{") else base64.b64decode(raw))
creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPES)
else:
creds = service_account.Credentials.from_service_account_file(KEY, scopes=SCOPES)
The base64 part came from a real failure. I first pasted the raw JSON key into the environment variable field of the cloud environment, and json.loads broke. The key is multi-line and its private_key is full of \n escapes, which don't survive a paste into a single-line env var field.
Base64 turns the whole key into one safe line:
# macOS
base64 -i ~/.config/gsc/henggana-sa.json | pbcopy
# Linux (-w0 disables line wrapping)
base64 -w0 ~/.config/gsc/henggana-sa.json
Paste that as GSC_KEY_JSON in the cloud environment settings.
The cloud environment also needs network access to two Google hosts. One mints the access token, the other is the API:
oauth2.googleapis.com
searchconsole.googleapis.com
Without them the script fails at the token request, before it ever reaches Search Console.
Common mistakes
Wrong siteUrl format
A domain property is sc-domain:example.com. A URL-prefix property is https://example.com/. They're different properties in Search Console, even for the same site. Use the siteUrl exactly as the sites call in step 4 returns it.
Granting a Google Cloud IAM role instead of Search Console access
Giving the service account Owner or Viewer on the Google Cloud project does nothing for Search Console. The only permission that counts is the user you add in Search Console's Users and permissions.
Forgetting to enable the API
The service account and key work without the API enabled, but every call fails until Google Search Console API is enabled on the project that owns the service account.
Committing the key
Keep the JSON key outside the repo, or at least gitignored. If it ever lands in git, delete that key in the Google Cloud console and create a new one.
References
- Search Console API: authorizing requests
- Search Analytics: query
- Managing owners, users, and permissions in Search Console
- Claude Code skills
Conclusion
To let a Claude Code skill use the Google Search Console API: enable the API in a Google Cloud project, create a service account with a JSON key, add its email to your property as a Restricted user, and wrap a small searchAnalytics/query script in a SKILL.md that tells Claude what the rows mean. For cloud sessions, pass the key as a base64 env var and allow oauth2.googleapis.com and searchconsole.googleapis.com.