SysEngineering

Harry — Helpdesk Agent

Harry is the natural-language Slack front end for IT requests. Employees DM him (“can I get Claude?”, “I’m locked out of Okta”, “how do I set up the VPN?”) and Harry either answers from the IT team’s own documentation with a citation, or files the right JSM request — where the helpdesk team’s workflows and automation take over (approval routing, Okta group assignment, provisioning).

Built EXACTLY like the live GW Admin Assistant (Greg): same FastAPI/Cloud Run skeleton, same Claude Opus 5 agentic tool loop, no intent lists or phrase matching anywhere. Harry decides nothing by keyword — the model reads the request and calls tools.

Nothing is trained or embedded. Harry can only state something written in a document it just read, and it links that document. Every answer is traceable, and a wrong answer is fixed by editing a page rather than retraining a model.

Status: both JSM flows proven live in IO prod. Filing is real when HARRY_DRY_RUN=false; the default is true. See HANDOFF.md for live deployment state and CLAUDE.md for design invariants and the decision log.

Division of labor (confirmed with the helpdesk team, 2026-08-21)

Piece Owner
Slack agent (this folder): NL understanding, catalog, entity stamping, policy checks, creating the IO ticket with the correct request type, requester auto-filled Systems Engineering (Adam)
Everything after the ticket exists: manager lookup via Okta (their OIDC app), approver assignment, approval notifications, Okta app assignment Helpdesk team (automations trigger off the request type)

Harry deliberately contains zero Okta code: manager resolution was considered and cut because their automation already does it the moment a 1780 ticket lands. A guardrail test fails the build if anyone adds an Okta client here.

The two flows

The helpdesk team runs two JSM request types on the IO service desk (serviceDeskId 67):

Flow Request type What happens after Harry files
manager_approval 1780 “Approved access request” Their automation pulls the requester’s manager from Okta, adds them as approver, fires the approval notification; app assigned after approval
auto_assign 1760 “Access request” Their automation assigns the app in Okta immediately (observed live: seconds)

Both are verified end to end in prod. On the approval path (IO-73276) the manager was resolved from Okta and set as approver 4 seconds after creation, and Product Type plus the Okta group add landed on approval.

Which flow an app takes lives in apps.py — two hardcoded tables (Claude → manager approval, Lucidchart → auto-assign), overridable via HARRY_APPS_JSON without a deploy. The model only ever sees catalog keys and flow names; request type IDs stay server-side. Every confirmed request = one IO ticket, created by a dedicated service account with raiseOnBehalfOf carrying the requester.

Their automation matches on the ticket SUMMARY — both the app name and the hyphenated entity label — so Harry’s catalog label is the join key. check_group_match.py replays their matcher offline; run it after any change on either side.

Entity status (For-Profit vs Non-Profit)

License type depends on the requester’s legal entity. entity_status.py ports the DOMAIN_TO_STATUS table from integrations/okta_entity_status/sync-entity-status.js: someone @theschmidt.org is Non-Profit no matter what anyone types; @hillspire.com is For-Profit. Derived from the verified Slack profile email, stamped into the JSM request, never overridable by the user or the model. Unmapped domain = Harry refuses to file and points at the regular helpdesk.

Knowledge layer — answering from documentation

25 whitelisted ITOPS guides, of which 5 support a step-by-step walkthrough with screenshots. The whitelist IS the security boundary: internal IHI pages are excluded because some contain agent-directed text, and a test pins every page id to the ITOPS space. The one policy document Harry reads from IHI — the per-app justification standard — lives in a separate structure that read_help_guide cannot reach, and Harry is forbidden from quoting or linking it.

A year of IO tickets showed 12.2% were about topics already documented in the user-facing ITOPS Confluence space. knowledge.py lets Harry answer those from the real guides, with a link.

Deliberately small and curated:

Policy enforcement — KnowBe4 training

The Claude guide requires completed AI Awareness training. training.py verifies it against KnowBe4’s Reporting API (read-only by construction — no write endpoints).

Not a model tool: the check runs inside submit_app_request, keyed off requires_training on the catalog entry, so the model can neither skip nor spoof it. The failure posture is asymmetric on purpose:

KnowBe4 says Harry does
explicitly not completed blocks, links the training dashboard
explicitly completed files, ticket stamped verified complete via KnowBe4
anything ambiguous (no token, API down, user or course not found) files as normal, stamped self-attested

Blocking on ambiguity would mean a drifted course name locks everyone out of Claude. With no KNOWBE4_API_TOKEN the gate is entirely inert.

Architecture

Employee ──DM/mention──▶ main.py (FastAPI, Slack Events API)
                           │  resolves requester email from Slack profile
                           │  injects their own open requests (read from Jira)
                           ▼
                         agent.py (Claude Opus 5 tool loop — Greg's, verbatim)
                           │  tools (helpdesk.py → active_tools()):
                           ├─ list_apps              catalog + flows
                           ├─ read_help_guide        one whitelisted ITOPS page
                           │                            (knowledge.py, flag-gated)
                           ├─ submit_app_request     → POST /rest/servicedeskapi/request
                           │                            duplicate guard → training gate
                           │                            → request type by flow, entity
                           │                            stamped, DRY-RUN by default
                           ├─ check_request_status   → GET, requester's own tickets only
                           ├─ cancel_request         → customer-facing Cancel transition,
                           │                            their own PENDING requests only
                           ├─ start_walkthrough      → step-by-step setup, for the 5
                           │                            guides that carry real steps
                           ├─ show_screenshot        → the guide's OWN screenshots,
                           │                            uploaded into the conversation
                           ├─ which_platform         → Mac/Windows from JSM Assets,
                           │                            silently, or ask
                           ├─ check_my_training      → their own KnowBe4 status, no filing
                           ├─ justification_standard → the helpdesk team's per-app bar,
                           │                            for JUDGING, never to recite
                           └─ file_help_ticket       → escalate to a human with the
                                                        diagnosis attached (type 1762)

Their JSM automation ──POST /jsm/notify──▶ main.py (notify.py)
   {"issue_key": "IO-123"}                   │  reads THAT ticket from Jira
   X-Harry-Secret: <shared>                  │  derives recipient + outcome
                                             ▼
                                           DM to the requester: approved /
                                           declined / withdrawn / provisioned

Guardrails live in code and test_harry.py, not prompts: the model can’t pass identity, entity status, or request-type IDs (reserved args are stripped); an app exists in exactly one flow table; dry-run is the default and missing JSM config forces it; status lookups only return the requester’s own tickets; and knowledge.py, training.py and monthly_report.py are asserted to contain no write path at all.

Five behaviours worth knowing, all added after live use found the need:

The improvement loop

monthly_report.py is a read-only monthly job that joins the month’s Jira tickets with Harry’s own harry_audit_v1 events and emits four lists. Tickets are the demand signal — they are never fed to Harry as knowledge.

List Signal Response
FIX a guide was read and the person filed a ticket anyway rewrite the guide
REACH Harry already files this, but it arrived by email/portal channel, not content
ADD genuinely uncovered demand write a guide / add an action
RETIRE guide never read obsolete, or its covers blurb is wrong

FIX is the performance loop — a per-document quality score. So the improvement mechanism is explicit: Harry doesn’t get smarter; the documented knowledge gets more complete and Harry’s reach over it gets wider, via a ranked backlog a human approves. Harry never edits Confluence or extends its own whitelist — the whitelist is the security boundary.

Running locally

pip install -r requirements.txt
pytest                                        # guardrail tests, no credentials needed

# Talk to Harry from the terminal — fastest way to tune the prompt (no Slack needed):
python try_harry.py "can i get claude?"
python try_harry.py --email someone@theschmidt.org "i need lucidchart"
python try_harry.py                           # interactive, keeps history

# Read-only recon:
python discover_jsm.py --check                # service account's JSM access
python discover_knowbe4.py --check --campaigns # KnowBe4 token + real course name
python check_group_match.py --poison          # replay their Okta-group matcher

# The monthly improvement report (read-only):
python monthly_report.py                      # FIX + RETIRE + metrics, no LLM cost
python monthly_report.py --days 30 --classify --out report.md

# File exactly one ticket, deliberately (prints the payload, needs --apply):
python file_test_ticket.py --app lucidchart --requester someone@hillspire.com

# Run the Slack service:
uvicorn main:app --port 8080

Say harry test to Harry in Slack for a live config/connectivity readout, or hit /diag — which reports the deployed commit, so “is my fix live?” is one look.

Deploy with .\deploy.ps1 (see HANDOFF.md). Flags are not sticky: pass -DryRunFlag false -KnowledgeFlag true every time you want both on.

Secrets

Google Secret Manager is the single machine-readable source, for both Cloud Run and local CLI runs. .env holds sm://<secret-name> pointers, which local_env.py resolves via the already-authenticated gcloud. The 1Password CLI is not installed here, so op:// references are skipped rather than resolved.

deploy.ps1 refuses to sync a pointer as a payload — doing so would overwrite the real credential with the literal reference string — and throws if a pointer’s target disagrees with the secret it mounts.

Configuration

Env var Notes
ANTHROPIC_API_KEY Same key as the GW Admin Assistant. LLM_PROVIDER / ANTHROPIC_MODEL (claude-opus-5) / ANTHROPIC_EFFORT (low) as in Greg; Vertex + OpenAI fallbacks intact
SLACK_BOT_TOKEN / SLACK_SIGNING_SECRET From the Harry Slack app. Live app is in the Hillspire production workspace (TP8A8CZ7H)
SLACK_ALLOWED_USER_IDS / SLACK_ALLOWED_TEAM_IDS / SLACK_ALLOWLIST_REQUIRED Same fail-closed posture as Greg. Slack member IDs are per workspace
ATLASSIAN_BASE_URL / ATLASSIAN_EMAIL / ATLASSIAN_API_TOKEN The dedicated service account that files tickets and reads Confluence guides. Needs agent/on-behalf permission on IO for raiseOnBehalfOf
JSM_SERVICE_DESK_ID / JSM_REQUEST_TYPE_APPROVAL_ID / JSM_REQUEST_TYPE_AUTO_ID Prod: 67 / 1780 / 1760. Sandbox IDs differ — re-discover
HARRY_DRY_RUN Default true. Filing returns a receipt; nothing is created. Only the literal false disables it
HARRY_KNOWLEDGE_ENABLED Default false. Turns on the whitelisted ITOPS documentation guides
KNOWBE4_API_TOKEN Optional. Unset = the training gate is inert (everything self-attested). KNOWBE4_BASE_URL for non-US accounts; KNOWBE4_TRAINING_MATCH if the course is renamed
HARRY_DUPLICATE_WINDOW_DAYS Default 1. 0 disables the duplicate guard — needed to demo or re-test the same app twice in a day
HARRY_RAISE_ON_BEHALF Default on: file with raiseOnBehalfOf=<requester>; auto-retries without it if the service account lacks the permission
HARRY_APPS_JSON / HARRY_GUIDES_JSON Optional catalog / guide-table overrides without a deploy
JSM_PROJECT_KEY Default IO. Scopes request-key linkification so ordinary prose isn’t rewritten
JSM_ENTITY_STATUS_FIELD_ID Optional: customfield id to carry entity status once the helpdesk team confirms one
HARRY_BUILD Stamped by deploy.ps1; surfaced in /diag
JSM_REQUEST_TYPE_HELP_ID Escalation target, 1762 (“Questions”). Unset = Harry cannot raise a help ticket and says so plainly. 1762 accepts a summary ONLY — the details attach as a comment
HARRY_NOTIFY_SECRET Optional. Unset = /jsm/notify returns 503 and the callback is dark. Set = their automation can call it with X-Harry-Secret. Compared in constant time
DIAG_TOKEN Gates /diag/llm