A teacher's setup guide · Google Apps Script + an AI assistant

Your own key to Apps Script

IT won't open the Classroom API and blocks third-party tools like clasp. You can still let an AI assistant write scripts and put them straight into your school Google account, using a key that belongs to you.

This is the detailed version. Prefer plain language and bigger type? Read the easy version.

One-time setup
about 45 min
Every script after that
minutes
Costs
$0 (Google side)
Asks IT for
nothing

Why this works when clasp doesn't

Every program that wants to touch your Google account has to introduce itself to Google first. Clasp introduces itself as a Google-owned app, and your district has a rule that says "unlisted outside apps may not connect to our accounts." So it's blocked at the door, and no amount of fiddling on your laptop changes that.

The workaround is not a trick. You create a tiny "app" of your own inside a Google Cloud project that lives in the school's own Google, and your push tool introduces itself as that. To the district's rule it's an app the school owns, made by one of its own users, asking for one narrow permission: edit Apps Script projects that you can already edit by hand.

Your laptop AI assistant + push.js Route A · clasp "Hi, I'm Google's clasp app" Route B · your own key "Hi, I'm an app this school's user made" Your school account Apps Script projects Docs · Gmail · Slides District app-access rule blocked: unlisted outside app allowed: the school's own app
Same laptop, same request, same rule. The only difference is who the tool says it is. Route B is what the rest of this page sets up.

This is the same mechanism a district-approved add-on uses. The difference is that you're the developer of a one-person app whose only user is you.

What it does and doesn't do

It does

Let a program on your computer create and update your Apps Script projects, the same ones you could open at script.google.com and type into by hand.

It doesn't

Unlock anything IT has locked. The Classroom API stays off. A script can only do what your account can already do, and it asks you for each permission the first time it runs.

Ground rules

Follow your district's AI and student-data policies as written. In practice: the AI writes code, the code runs inside Google. Never paste student names, emails, or work into an AI chat. Ask for scripts that read a roster or an inbox on Google's side and show you only what you need.

Level 1: no setup at all

Before the 45-minute setup, know that this already works today, for free, in five minutes:

  1. Ask an AI assistant (Claude at claude.ai works well) for the script. Describe the goal and the Google apps involved. Example: "Write a Google Apps Script that finds every email from @students.myschool.org in the last two weeks with a photo attached and builds a Slides deck, one photo per slide, with the student's email under it."
  2. Go to script.google.com in your school account, click New project, delete the placeholder, paste the code, save.
  3. Pick the function in the toolbar, click Run, approve the permissions it asks for.

What Level 1 costs you is every round trip: each fix means copying code back and forth, and the AI can't see what happened. Level 2 removes the copying. The AI writes the file, pushes it, and reads the result; you only run things and look at what comes out.

Level 2: the one-time setup

Eight steps. Do them in order, in one sitting if you can, signed in to your school Google account the whole time. Each step says what you'll see when it worked.

1

Install two free programs

about 10 minyour laptop
  • Node.js from nodejs.org. Choose the "LTS" download and accept the defaults. This is the engine that runs the two small helper scripts.
  • Claude Code from claude.com/claude-code. The desktop app is the easiest. This is the AI assistant that can create files and run commands on your computer, which is what makes direct pushing possible.
It worked whenClaude Code opens, and typing node --version in its terminal prints a version number starting with 18 or higher.
2

Make a Google Cloud project

about 5 minschool account, browser

Go to console.cloud.google.com. Confirm the account in the top-right corner is your school account. Use the project picker at the top to choose New project, give it any name ("my-scripts" is fine), and create it. Then:

  1. Open the menu, choose APIs & ServicesLibrary.
  2. Search for Apps Script API, open it, click Enable.
It worked whenthe Apps Script API page shows Manage instead of Enable.
"I can't create a project" or the console won't open

Some districts switch off Google Cloud for staff accounts. If you see a message about your organization not allowing this, this guide stops here for you: there is no key without a project. Level 1 still works, and this specific ask (turn on Google Cloud project creation for teachers) is much smaller than "open the Classroom API".

3

Create your key

about 10 minschool account, browser

Still in the Cloud console, open the menu and choose Google Auth platform. Two sub-steps:

  1. Branding (Google may call this the consent screen). Click Get started. App name: anything ("My Apps Script key"). Support email: yours. Audience: choose Internal. Internal means "only people in this school's Google can use this app," which is exactly what keeps it from needing Google's review. Contact email: yours. Agree, create.
  2. Clients. Click Create client. Application type: Desktop app. Name: anything. Create, then Download JSON. Rename the downloaded file to client_secret.json.

Put that file in a folder named .config/gas-oauth inside your home folder. On a Mac that's /Users/you/.config/gas-oauth/, on Windows C:\Users\you\.config\gas-oauth\. Ask Claude Code to make the folder and move the file if the dot-folder is awkward to find.

It worked whenopening client_secret.json in a text editor shows a line starting with "installed":. If it says "web": instead, you picked the wrong application type. Make another client as Desktop app.
Keep it private

That file is the key. Don't email it, don't paste it into a chat, don't put it in a shared drive. If it ever leaks, delete the client in the Cloud console and make a new one.

4

Flip one switch in Apps Script

1 minschool account, browser

Go to script.google.com/home/usersettings and turn Google Apps Script API on. Without this, every push fails with a message that says exactly that.

It worked whenthe toggle reads "On".
5

Save the two helper scripts

5 minClaude Code

Two small files do all the work: auth.js signs you in once, push.js sends a script to Google. You don't need to read them. Copy each block, paste it into Claude Code, and say: "Save this as auth.js (then push.js) in a folder called gas-ai-kit in my home folder."

Or download them: auth.js · push.js. Put both in a folder called gas-ai-kit in your home folder.

gas-ai-kit/auth.js
#!/usr/bin/env node
// gas-auth.js — ONE-TIME sign-in so an AI assistant can push code into YOUR
// Google Apps Script projects. Needs ~/.config/gas-oauth/client_secret.json
// (downloaded from your own Google Cloud project — see the guide, step 3).
// Writes ~/.config/gas-oauth/token.json. Run again only if access is revoked.
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const readline = require('readline');

const DIR = path.join(os.homedir(), '.config', 'gas-oauth');
const SECRET = path.join(DIR, 'client_secret.json');
const TOKEN = path.join(DIR, 'token.json');
const SCOPES = [
  'https://www.googleapis.com/auth/script.projects',    // create + update script code
  'https://www.googleapis.com/auth/script.deployments', // publish web-app deployments
];
const REDIRECT = 'http://localhost';

(async () => {
  if (!fs.existsSync(SECRET)) {
    console.error('Missing ' + SECRET);
    console.error('Download the Desktop-app client JSON from Google Cloud (guide step 3), rename it client_secret.json, and put it there.');
    process.exit(1);
  }
  const installed = JSON.parse(fs.readFileSync(SECRET, 'utf8')).installed;
  if (!installed) {
    console.error('client_secret.json is not a "Desktop app" client (no "installed" key). Create a Desktop app client and download that JSON.');
    process.exit(1);
  }
  const url = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({
    client_id: installed.client_id,
    redirect_uri: REDIRECT,
    response_type: 'code',
    scope: SCOPES.join(' '),
    access_type: 'offline',
    prompt: 'consent',
  });
  console.log('\n1. Open this link in a browser where you are signed in to your SCHOOL account:\n');
  console.log(url + '\n');
  console.log('2. Click Allow. The browser then shows a "localhost refused to connect" page. That is expected.');
  console.log('3. Copy the WHOLE address from that page\'s address bar and paste it below.\n');
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const pasted = await new Promise((r) => rl.question('Paste the address: ', (a) => { rl.close(); r(a.trim()); }));
  let code = null;
  try { code = new URL(pasted).searchParams.get('code'); } catch (e) { /* not a URL */ }
  if (!code) { const m = pasted.match(/code=([^&\s]+)/); code = m ? decodeURIComponent(m[1]) : null; }
  if (!code) {
    console.error('No code found. Paste the entire address, it starts with http://localhost/?code=');
    process.exit(1);
  }
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code: code,
      client_id: installed.client_id,
      client_secret: installed.client_secret,
      redirect_uri: REDIRECT,
      grant_type: 'authorization_code',
    }),
  });
  const data = await res.json();
  if (!res.ok || !data.refresh_token) {
    console.error('Google did not return a long-lived token:\n' + JSON.stringify(data, null, 2));
    console.error('If everything else looks fine but refresh_token is missing: remove the app at https://myaccount.google.com/permissions and run this again.');
    process.exit(1);
  }
  fs.mkdirSync(DIR, { recursive: true });
  fs.writeFileSync(TOKEN, JSON.stringify({
    refresh_token: data.refresh_token,
    access_token: data.access_token,
    expires_in: data.expires_in,
    scope: data.scope,
    token_type: data.token_type,
  }, null, 2) + '\n', { mode: 0o600 });
  try { fs.chmodSync(SECRET, 0o600); } catch (e) { /* Windows */ }
  console.log('\nSaved ' + TOKEN + '. Done. You will not need to do this again.');
})().catch((e) => { console.error(e.message); process.exit(1); });
gas-ai-kit/push.js
#!/usr/bin/env node
// gas-push.js — push the files in ./gas/ to a Google Apps Script project.
// Run it inside a project folder that contains:
//   gas.json            {"title": "My Script"}   (scriptId is filled in on first push)
//   gas/appsscript.json the manifest (time zone, scopes)
//   gas/*.gs            the code   (gas/*.html for web pages, optional)
// First run creates the project in the signed-in Google account; later runs update it.
// Uses ~/.config/gas-oauth/ from gas-auth.js. No clasp, no npm installs.
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');

const CRED_DIR = path.join(os.homedir(), '.config', 'gas-oauth');
const CLIENT_SECRET_PATH = path.join(CRED_DIR, 'client_secret.json');
const TOKEN_PATH = path.join(CRED_DIR, 'token.json');
const CONFIG_PATH = path.resolve('gas.json');
const SRC_DIR = path.resolve('gas');
const API = 'https://script.googleapis.com/v1';

const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
const writeJson = (p, o) => fs.writeFileSync(p, JSON.stringify(o, null, 2) + '\n');

async function refreshAccessToken() {
  if (!fs.existsSync(TOKEN_PATH)) throw new Error('No ' + TOKEN_PATH + ' - run gas-auth.js first.');
  const secret = readJson(CLIENT_SECRET_PATH).installed;
  const token = readJson(TOKEN_PATH);
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: secret.client_id,
      client_secret: secret.client_secret,
      refresh_token: token.refresh_token,
      grant_type: 'refresh_token',
    }),
  });
  if (!res.ok) throw new Error('Sign-in expired or revoked (' + res.status + '). Run gas-auth.js again.');
  const fresh = await res.json();
  token.access_token = fresh.access_token;
  token.expires_in = fresh.expires_in;
  writeJson(TOKEN_PATH, token);
  return fresh.access_token;
}

async function api(accessToken, method, url, body) {
  const res = await fetch(url, {
    method,
    headers: { Authorization: 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  if (!res.ok) {
    if (/not enabled the Apps Script API/i.test(text)) {
      throw new Error('Turn on the Apps Script API for your account at https://script.google.com/home/usersettings and run again.');
    }
    throw new Error(method + ' ' + url + ' -> ' + res.status + ' ' + text.slice(0, 400));
  }
  return text ? JSON.parse(text) : {};
}

function loadSource() {
  if (!fs.existsSync(SRC_DIR)) throw new Error('No gas/ folder here. Run this inside your project folder.');
  const files = [];
  for (const name of fs.readdirSync(SRC_DIR)) {
    const full = path.join(SRC_DIR, name);
    if (!fs.statSync(full).isFile()) continue;
    const source = fs.readFileSync(full, 'utf8');
    if (name === 'appsscript.json') files.push({ name: 'appsscript', type: 'JSON', source });
    else if (name.endsWith('.gs')) files.push({ name: name.replace(/\.gs$/, ''), type: 'SERVER_JS', source });
    else if (name.endsWith('.html')) files.push({ name: name.replace(/\.html$/, ''), type: 'HTML', source });
  }
  if (!files.some((f) => f.name === 'appsscript')) throw new Error('gas/appsscript.json is required (the manifest).');
  return files;
}

(async () => {
  if (!fs.existsSync(CONFIG_PATH)) throw new Error('No gas.json here. Create one: {"title": "My Script"}');
  const config = readJson(CONFIG_PATH);
  if (!config.title) throw new Error('gas.json needs a "title".');
  const accessToken = await refreshAccessToken();

  if (!config.scriptId) {
    console.log('Creating Apps Script project "%s"...', config.title);
    const created = await api(accessToken, 'POST', API + '/projects', { title: config.title });
    config.scriptId = created.scriptId;
    writeJson(CONFIG_PATH, config);
  } else {
    console.log('Updating existing project %s', config.scriptId);
  }

  const files = loadSource();
  await api(accessToken, 'PUT', API + '/projects/' + config.scriptId + '/content', { files });
  config.lastPushedAt = new Date().toISOString();
  config.editorUrl = 'https://script.google.com/d/' + config.scriptId + '/edit';
  writeJson(CONFIG_PATH, config);

  console.log('Pushed %d file(s): %s', files.length, files.map((f) => f.name).join(', '));
  console.log('Open it: %s', config.editorUrl);
})().catch((err) => {
  console.error('push failed: ' + err.message);
  process.exit(1);
});
It worked whenyour home folder has gas-ai-kit/auth.js and gas-ai-kit/push.js. Nothing runs yet.
6

Sign in once

5 minClaude Code + browser

In Claude Code's terminal, run:

node ~/gas-ai-kit/auth.js

It prints a long Google link. Open it in a browser where your school account is signed in, and click Allow. Google then sends the browser to a page that says it can't connect to localhost. That's on purpose: the page fails, but its address now contains your sign-in code.

Copy the entire address from the address bar. It looks like this:

http://localhost/?iss=https://accounts.google.com&code=4/0AbCd…&scope=https://www.googleapis.com/auth/script.projects%20…

Paste it into the terminal where auth.js is waiting and press Enter.

It worked whenthe terminal says Saved …/token.json. Done. That file is a long-lived pass; push.js renews it silently for as long as you keep the app authorized.
Google shows "Access blocked" or "This app isn't verified"

Almost always the audience in step 3 was set to External instead of Internal, or the browser is signed in to a personal Gmail rather than the school account. Fix the audience (Google Auth platform → Audience) or switch accounts, then run auth.js again.

7

Push a test script

5 minClaude Code

Paste this into Claude Code:

Make a folder called hello-script in my home folder with:
- gas.json containing {"title": "Hello from my laptop"}
- gas/appsscript.json with timeZone "America/New_York", runtimeVersion "V8", exceptionLogging "STACKDRIVER"
- gas/Code.gs with one function hello() that logs "It works" with Logger.log
Then run: cd ~/hello-script && node ~/gas-ai-kit/push.js
and show me the link it prints.

Open the link. You're looking at a real Apps Script project that was created from your laptop. Pick hello in the toolbar, click Run, and read the log at the bottom.

It worked whenthe execution log shows It works. The project is also listed at script.google.com under My projects.
The push said it worked but I don't see the project

Check the account in the top-right corner of the Apps Script page. The project is in the account that clicked Allow in step 6, and it's easy to have a personal Gmail open in the same browser. Switch accounts and the project appears. This is the first thing that went wrong for us.

8

Tell your AI how it works

2 minClaude Code

Claude Code reads a file called CLAUDE.md in your home folder at the start of every conversation. Put the workflow there once and you'll never have to explain it again. Paste this and say "add this to my CLAUDE.md":

# Google Apps Script workflow

I am a teacher. My school Google account runs Apps Script projects; IT blocks
clasp and third-party OAuth apps, so we push through my own OAuth client.

- One Apps Script project = one folder with gas.json ({"title": ...}) and gas/
  (appsscript.json + *.gs). Push with: node ~/gas-ai-kit/push.js (run inside
  the folder). First push creates the project and writes its scriptId + editor
  link into gas.json; later pushes update it.
- Credentials live in ~/.config/gas-oauth/ (client_secret.json + token.json).
  Never read them aloud, print them, copy them elsewhere, or commit them.
- I run functions from the Apps Script editor's Run dropdown, which only calls
  functions that take no arguments. Give every script a preview or dry-run
  function that logs what it WOULD do before a function that does it.
- Put the scopes a script needs in gas/appsscript.json oauthScopes, using the
  scopes the Apps Script reference lists for each service (GmailApp needs
  https://mail.google.com/, DriveApp needs .../auth/drive).
- Student names, emails, and work never go into chat, code comments, or logs
  I might share. Scripts may read them inside Google; you may not.
- After every push, give me the editor link and the exact run order.
It worked whenyou start a new conversation, ask for a script, and Claude Code creates the folder, pushes it, and hands you the link without being told how.

Asking for scripts that work

The setup is done. From here the skill is in the asking. What made our scripts land on the first or second try:

  • Say where the data lives and what the output is. "Emails from @students… in the last two weeks with image attachments → one Slides deck, one image per slide, name and email underneath, grouped by class section from this roster doc: [link]."
  • Ask for a preview function. "Give me a previewRoster() that logs what it parsed, and a dryRun() that counts what it would do, before buildDeck() does it." You run the previews first; nothing is created until you've seen the plan.
  • Ask for a receipt. "When it finishes, email me the link and counts." Google's execution log is easy to miss; an email is not.
  • Describe your documents, don't upload them. "The roster is a Google Doc with one tab per class; each tab lists students one per line." The script reads the doc inside Google. You never paste the names.
  • Report what you saw, not what you guess. "It ran for two minutes and the log ends with 'Exception: Specified permissions are not sufficient'." Paste the exact line; the fix is usually one manifest edit and a re-push.
A real example

The student-photo deck above went from request to a working deck in one morning: the AI wrote it, wrote its own tests, pushed it, a second AI reviewed it and found three real bugs, the fixes were pushed, and the teacher ran three functions in order. The teacher's part was under ten minutes.

When something goes wrong

You seeIt meansDo this
User has not enabled the Apps Script APIStep 4 was skipped.Turn it on at script.google.com/home/usersettings, push again.
"Access blocked" / "app isn't verified" in the browserAudience is External, or wrong Google account.Set audience to Internal; sign in to the school account; run auth.js again.
Push succeeds, project not listedYou're looking at a different account.Switch accounts (top-right avatar). Same fix if the script "can't find" a doc.
Specified permissions are not sufficient when runningThe manifest lists a scope narrower than the service needs.Tell the AI the exact message; it adds the scope to appsscript.json and re-pushes. Re-approve on next run.
refresh_token missing after sign-inGoogle reused an old approval.Remove the app at myaccount.google.com/permissions, run auth.js again.
Run dropdown doesn't list my functionIt takes arguments, or the push didn't include it.Ask for a no-argument wrapper; check the editor shows the latest code.
Script stops around 6 minutesApps Script's per-run time limit.Ask for a script that saves progress and continues itself (a trigger). Ours does this.
Colleague can't open my web appWeb apps are locked to your domain, and anonymous access is district-blocked.Expected. Share to people in the school domain; the sign-in prompt is normal.

Revoking everything takes one minute: delete the client in Google Cloud and remove the app at myaccount.google.com/permissions. Your scripts keep working; only the laptop's ability to push stops.

Written from one teacher's working setup in a district that blocks clasp and the Classroom API. Console menu names checked against Google's Apps Script API quickstart in September 2026; Google renames things, so if a menu isn't where this says, search the console for the bolded term.