Agentspace License Dashboard: Dev Log & Runbook

What Was Built

A weekly automated process that:

  1. Pulls all user license records from Google Agentspace (marketed as “Gemini Enterprise Plus”) via the GCP Discovery Engine API
  2. Flags ASSIGNED users who haven’t logged in for 14+ days
  3. Generates a self-contained HTML dashboard
  4. Git commits and pushes to sharehub.zorro.hk/gemini-license-dashboard.html
  5. Runs automatically every Monday at 9am via cron

Live dashboard: https://sharehub.zorro.hk/gemini-license-dashboard.html
Script location: ~/Dev/gcloud/gemini-license-check.py
GCP project: solutionday-cloudsummit


The Investigation: What We Tried First (and Why It Failed)

This section exists so you don’t repeat the same dead ends.

Dead End 1 — gcloud gemini CLI

gcloud gemini --help
# Groups: code-repository-indexes, code-tools-settings,
#         gemini-gcp-enablement-settings, logging-settings...

No user-license subcommand exists in any gcloud gemini group (stable, beta, or alpha). The gcloud CLI covers configuration settings only, not license assignment.

Dead End 2 — Admin SDK Licensing API (licensing.googleapis.com)

This is the Google Workspace licensing API for Workspace products (Gmail, Drive, etc.). It requires:

  • The apps.licensing OAuth scope
  • A Workspace super admin account

Standard gcloud auth login doesn’t include this scope. Attempting gcloud auth application-default login --scopes=...apps.licensing triggers a “unsafe application” block from Google.
Wrong product entirely — Agentspace is a GCP product, not a Workspace product.

Dead End 3 — cloudaicompanion.googleapis.com

The Gemini for Google Cloud API. Confirmed enabled on the project. But its full API surface (via discovery) only exposes settings bindings:

  • codeToolsSettings, loggingSettings, geminiGcpEnablementSettings, releaseChannelSettings

No user-license endpoints. The --product flag only accepts: gemini-cloud-assist, gemini-code-assist, gemini-in-bigquery, gemini-in-looker.

What Actually Works — discoveryengine.googleapis.com

The product behind “Gemini Enterprise Plus” in the GCP Console is Google Agentspace, which runs on the Discovery Engine API. The Console’s “Manage users” page calls this API directly.

Key insight: Standard gcloud auth login credentials with the cloud-platform scope are sufficient. No Workspace admin access needed.


The API

Base URL:

https://discoveryengine.googleapis.com/v1alpha/projects/{PROJECT}/locations/global/userStores/default_user_store/userLicenses

Required headers:

Authorization: Bearer <gcloud auth print-access-token>
x-goog-user-project: solutionday-cloudsummit

The x-goog-user-project header is mandatory — the API refuses calls without a quota project.

Response structure (one record):

{
  "userPrincipal": "user@domain.com",
  "user": "projects/745425319636/locations/global/userStores/default_user_store/users/<hash>",
  "licenseAssignmentState": "ASSIGNED",
  "licenseConfig": "projects/.../licenseConfigs/free_trial_agent_space",
  "createTime": "2026-03-18T10:07:06Z",
  "updateTime": "2026-05-08T03:12:00Z",
  "lastLoginTime": "2026-05-08T03:12:00Z"
}

licenseAssignmentState values: | Value | Meaning | |——-|———| | ASSIGNED | Has a license | | NO_LICENSE | In the system, no license assigned | | NO_LICENSE_ATTEMPTED_LOGIN | Tried to log in but has no license |


The Complete Script

Save at ~/Dev/gcloud/gemini-license-check.py. No external dependencies — stdlib only.

#!/usr/bin/env python3
"""
Weekly Gemini Enterprise (Agentspace) license inactivity checker.
Fetches user license data, flags ASSIGNED users inactive for 14+ days,
generates an HTML dashboard, and pushes to sharehub for publishing.
"""

import urllib.request
import json
import subprocess
import os
from datetime import datetime, timezone, timedelta

# ── Config ────────────────────────────────────────────────────────────────────
PROJECT         = "solutionday-cloudsummit"
INACTIVITY_DAYS = 14                                    # change threshold here
SHAREHUB_DIR    = os.path.expanduser("~/Dev/sharehub")
OUTPUT_FILE     = os.path.join(SHAREHUB_DIR, "gemini-license-dashboard.html")
API_BASE        = (
    f"https://discoveryengine.googleapis.com/v1alpha/projects/{PROJECT}"
    f"/locations/global/userStores/default_user_store/userLicenses"
)

# ── Fetch data ────────────────────────────────────────────────────────────────
def get_token():
    return subprocess.check_output(
        ["gcloud", "auth", "print-access-token"]
    ).decode().strip()

def fetch_all_licenses(token):
    headers = {
        "Authorization": f"Bearer {token}",
        "x-goog-user-project": PROJECT,
    }
    rows, page_token = [], ""
    while True:
        url = API_BASE + "?pageSize=100" + (f"&pageToken={page_token}" if page_token else "")
        req = urllib.request.Request(url, headers=headers)
        resp = json.loads(urllib.request.urlopen(req).read())
        rows.extend(resp.get("userLicenses", []))
        page_token = resp.get("nextPageToken", "")
        if not page_token:
            break
    return rows

# ── Analyse ───────────────────────────────────────────────────────────────────
def analyse(rows):
    now    = datetime.now(timezone.utc)
    cutoff = now - timedelta(days=INACTIVITY_DAYS)
    assigned = [r for r in rows if r.get("licenseAssignmentState") == "ASSIGNED"]

    inactive, active = [], []
    for u in assigned:
        raw = u.get("lastLoginTime", "")
        if raw:
            last     = datetime.fromisoformat(raw.replace("Z", "+00:00"))
            days_ago = (now - last).days
            if last < cutoff:
                inactive.append({**u, "_days": days_ago})
            else:
                active.append({**u, "_days": days_ago})
        else:
            inactive.append({**u, "_days": None})   # assigned but never logged in

    inactive.sort(key=lambda x: (x["_days"] is None, -(x["_days"] or 9999)))
    return assigned, active, inactive, now

# ── HTML generation ───────────────────────────────────────────────────────────
def badge(days):
    if days is None:
        return '<span class="badge never">Never logged in</span>'
    if days > 30:
        return f'<span class="badge critical">{days}d ago</span>'
    return f'<span class="badge warn">{days}d ago</span>'

def render_row(u):
    email    = u.get("userPrincipal", "")
    raw      = u.get("lastLoginTime", "")
    last_str = raw[:10] if raw else "—"
    days     = u.get("_days")
    css      = "never" if days is None else ("critical" if days > 30 else "warn")
    return f"""
      <tr class="row-{css}">
        <td>{email}</td>
        <td>{last_str}</td>
        <td>{badge(days)}</td>
      </tr>"""

def generate_html(rows, assigned, active, inactive, generated_at):
    no_license_count = sum(1 for r in rows if r.get("licenseAssignmentState") == "NO_LICENSE")
    attempted_count  = sum(1 for r in rows if r.get("licenseAssignmentState") == "NO_LICENSE_ATTEMPTED_LOGIN")
    rows_html        = "\n".join(render_row(u) for u in inactive)
    ts               = generated_at.strftime("%Y-%m-%d %H:%M UTC")
    next_run         = (generated_at + timedelta(days=7)).strftime("%Y-%m-%d")

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gemini Enterprise License Dashboard</title>
<style>
  :root 
  *
  body
  header
  h1
  .meta
  .cards
  .card
  .card .num
  .card .lbl
  .card.green .num.card.amber .num
  .card.red .num.card.blue .num
  .card.gray .num
  section
  h2
  table
  th
  td
  tr:last-child td
  .badge
  .badge.warn
  .badge.critical
  .badge.never
  .empty
  footer
</style>
</head>
<body>
<header>
  <div>
    <h1>Gemini Enterprise · License Dashboard</h1>
    <p style="color:var(--muted);font-size:.85rem;margin-top:.25rem">
      Project: {PROJECT} &nbsp;·&nbsp; Inactive threshold: {INACTIVITY_DAYS} days
    </p>
  </div>
  <div class="meta">Updated: {ts}<br>Next check: {next_run}</div>
</header>
<div class="cards">
  <div class="card blue"><div class="num">{len(assigned)}</div><div class="lbl">Assigned licenses</div></div>
  <div class="card green"><div class="num">{len(active)}</div><div class="lbl">Active (≤{INACTIVITY_DAYS}d)</div></div>
  <div class="card red"><div class="num">{len(inactive)}</div><div class="lbl">Inactive (&gt;{INACTIVITY_DAYS}d)</div></div>
  <div class="card gray"><div class="num">{no_license_count}</div><div class="lbl">No license</div></div>
  <div class="card amber"><div class="num">{attempted_count}</div><div class="lbl">Attempted login</div></div>
  <div class="card gray"><div class="num">{len(rows)}</div><div class="lbl">Total users</div></div>
</div>
<section>
  <h2>⚠️ Assigned users inactive for &gt;{INACTIVITY_DAYS} days ({len(inactive)})</h2>
  {"<p class='empty'>✅ All assigned users are active!</p>" if not inactive else f"""
  <table>
    <thead><tr><th>Email</th><th>Last Login</th><th>Status</th></tr></thead>
    <tbody>{rows_html}</tbody>
  </table>"""}
</section>
<footer>Auto-generated by gemini-license-check.py · sharehub.zorro.hk</footer>
</body>
</html>"""

# ── Git push to sharehub ──────────────────────────────────────────────────────
def publish(generated_at):
    msg = f"Auto: Gemini license dashboard {generated_at.strftime('%Y-%m-%d')}"
    subprocess.run(["git", "-C", SHAREHUB_DIR, "add", "gemini-license-dashboard.html"], check=True)
    subprocess.run(["git", "-C", SHAREHUB_DIR, "commit", "-m", msg], check=True)
    subprocess.run(["git", "-C", SHAREHUB_DIR, "push", "origin", "main"], check=True)
    print(f"Published: https://sharehub.zorro.hk/gemini-license-dashboard.html")

# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    print("Fetching license data...")
    token = get_token()
    rows  = fetch_all_licenses(token)
    print(f"  {len(rows)} users fetched")

    assigned, active, inactive, now = analyse(rows)
    print(f"  {len(assigned)} assigned · {len(active)} active · {len(inactive)} inactive")

    html = generate_html(rows, assigned, active, inactive, now)
    with open(OUTPUT_FILE, "w") as f:
        f.write(html)
    print(f"Dashboard written: {OUTPUT_FILE}")

    publish(now)

Setup & Prerequisites

1. Authenticate gcloud

gcloud auth login
# Log in as any GCP IAM user with Viewer access to solutionday-cloudsummit

2. Verify the script runs

python3 ~/Dev/gcloud/gemini-license-check.py

Expected output:

Fetching license data...
  106 users fetched
  50 assigned · 36 active · 14 inactive
Dashboard written: ~/Dev/sharehub/gemini-license-dashboard.html
Published: https://sharehub.zorro.hk/gemini-license-dashboard.html

3. The cron job (already set up)

0 9 * * 1  /usr/bin/python3 /Users/zorro/Dev/gcloud/gemini-license-check.py >> /tmp/gemini-license-check.log 2>&1

Runs every Monday at 09:00. Check logs at /tmp/gemini-license-check.log.

To edit: crontab -e
To verify: crontab -l | grep gemini


How to Customise

Change the inactivity threshold

Edit line in the Config section:

INACTIVITY_DAYS = 14   # change to 7, 30, etc.

Change the GCP project

PROJECT = "your-project-id"

The userStore ID default_user_store is standard across all Agentspace projects.

Save a CSV instead of (or alongside) HTML

Add after fetch_all_licenses():

import csv
with open("licenses.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["userPrincipal","licenseAssignmentState","lastLoginTime"])
    w.writeheader()
    w.writerows(rows)

Filter to a specific domain

rows = [r for r in rows if "@hkmci.com" in r.get("userPrincipal","")]

Add email notification for inactive users

After analyse(), send a summary email using smtplib or pipe to a Slack webhook — the inactive list has everything you need.

Publish to a different path

Change OUTPUT_FILE and update the git add path in publish().


Key Facts for Future Reference

Item Value
GCP project solutionday-cloudsummit
API discoveryengine.googleapis.com
API version v1alpha
UserStore default_user_store
Auth scope needed cloud-platform (standard gcloud)
Subscription ID free_trial_agent_space
Dashboard URL sharehub.zorro.hk/gemini-license-dashboard.html
Script ~/Dev/gcloud/gemini-license-check.py
Cron schedule Every Monday 09:00
Log file /tmp/gemini-license-check.log

Created: 2026-05-08