Penn Claude Academy training badges

Penn Claude Academy training badges

Claude Academy is Anthropic's free training site; each completed course awards a badge. Penn lets people claim their badge on a Grouper self-service screen, which verifies it with Anthropic and puts them in a group for that course. Course groups can then be intersected with an access policy to make training a prerequisite.

Replace <yourRoot> with your top-level folder and netid@yourinstitution.edu with your EPPN format.

How it works

  1. The person pastes their badge link into a GSH template screen.

  2. The screen verifies it with Claude Academy and checks the name on the badge is their EPPN.

  3. The claim is written to the claude_academy_badge table. Each badge can be claimed once.

  4. The screen runs a SQL_GROUP_LIST loader inline, creating one group per course, e.g. <yourRoot>:training:claudeAcademy:courses:claudeCode101. New courses get groups automatically.

What learners do

  1. Take a course at academy.claude.com.

  2. On the badge, click Edit name and set it to netid@yourinstitution.edu.

  3. Click Copy link under the badge and paste it into the claim screen.

The name is per badge. Changing the Academy profile name does not change an already-issued badge. Always tell people to use Edit name on the badge itself.

Don't copy the address bar. academy.claude.com/badges/<uuid> can't be verified. Copy link gives academy.claude.com/verify/<32 hex>. The template detects the wrong one and says so.

Verifying a badge

Don't scrape the verify page: it is a client-side app that returns identical HTML for real and fake codes. Call the endpoint the page uses:

POST https://academy.claude.com/api/anthropic.academy_public.api.v1alpha.AcademyPublicService/VerifyCertificate {"verifyCode":"<32 hex>"} valid: {"valid":true,"certificateName":"...","courseTitle":"...","issuedAt":"..."} invalid: {} (still HTTP 200)

Test the valid field, not the status code. This is not a published API and may change without notice. The template treats a non-200 as an error, so an outage never rejects good badges.

Identity model

A badge only proves somebody finished a course: the name is free text and the link is shareable. The binding is convention -- the badge name must equal the EPPN of whoever is running the screen -- plus a unique index so each badge is claimed once, by one person. Treat it as an attendance record for enablement training.

Table

CREATE TABLE claude_academy_badge ( id BIGSERIAL NOT NULL, subject_id VARCHAR(100) NOT NULL, netid VARCHAR(100) NOT NULL, badge_source VARCHAR(30) NOT NULL DEFAULT 'academy', verify_code VARCHAR(64) NOT NULL, course_title VARCHAR(200) NOT NULL, course_key VARCHAR(100) NOT NULL, -- 'Claude Code 101' -> claudeCode101 certificate_name VARCHAR(200) NOT NULL, -- name on the badge, for audit issued_at TIMESTAMP, -- UTC verified_at TIMESTAMP NOT NULL DEFAULT now(), -- UTC PRIMARY KEY (id) ); CREATE UNIQUE INDEX claude_academy_badge_code_idx ON claude_academy_badge (verify_code); CREATE INDEX claude_academy_badge_sid_idx ON claude_academy_badge (subject_id); CREATE INDEX claude_academy_badge_course_idx ON claude_academy_badge (course_key);

PostgreSQL. For MySQL use BIGINT AUTO_INCREMENT; for Oracle, GENERATED BY DEFAULT AS IDENTITY and CURRENT_TIMESTAMP.

Loader

On <yourRoot>:training:claudeAcademy:claudeAcademyCourseLoader: SQL_GROUP_LIST, daily cron (backstop only), grouperLoaderGroupsLike = <yourRoot>:training:claudeAcademy:courses:%, grouperLoaderFailsafeUse = F (the groups are small, so default guards block real removals).

select '<yourRoot>:training:claudeAcademy:courses:' || course_key as group_name, subject_id, 'yourPersonSourceId' as subject_source_id from claude_academy_badge where badge_source = 'academy'
  • || works on PostgreSQL and Oracle; MySQL needs CONCAT().

  • Use fully qualified names if the loader group sits outside the folder it fills. Bare names are created next to the loader, outside grouperLoaderGroupsLike, and never cleaned up.

Implementation notes

  • runAsType = GrouperSystem is correct. The current subject is still the logged-in user (GshTemplateExec sets it independently); GrouperSystem is only so the inline loader run has privilege. Don't "fix" it.

  • Runner group. securityRunType = specifiedGroup on a dedicated group containing your "current affiliate" reference group, so the audience changes without editing template config.

  • Inline loader. Job name SQL_GROUP_LIST__<groupName>__<groupUuid>, built at runtime. About 3 seconds. A failure is logged, not fatal; the schedule catches up.

  • Escape API values with GrouperUtil.escapeHtml(value, true). Template output renders as raw HTML and the badge name is attacker-controlled.

  • Store UTC, display local. Otherwise evening completions show the next day.

  • Course key strips all non-alphanumerics and camel-cases, which also stops a : in a title from injecting into a group name.

Requiring training

If access comes from a computed group, intersect it with a course group to require the training. This only removes people, so first report who has access without the course and tell them. Pilot with one department's group.

Attachments

File

What it is

File

What it is

The GSH template. Institution settings are constants at the top.

Template config; loader settings in comments.