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.
| 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 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.
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.
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:
HARRY_KNOWLEDGE_ENABLED through helpdesk.active_tools(), so when off the
tool is absent from the schemas and from execution.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.
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:
acknowledge_duplicate after being told one exists.classify_request() returns delivered / processing
/ awaiting_approval / approval_overdue / stalled with per-flow thresholds, because the
two slow cases need opposite advice: a stalled auto-assign ticket may need re-filing,
while a duplicate can never speed up a human approval.walkthrough: True, because an audit found most are
reference prose with no steps to follow. Harry never invents a step: where the guide
stops it says so and offers a person. A confident wrong instruction sends a
non-technical user hunting for a button that does not exist./jsm/notify when a
ticket moves and Harry DMs the requester, so the person who asked Harry hears back from
Harry rather than a second bot. The payload is a pointer, never content — the only
field read is the issue key, and everything else is derived from what Jira holds. The
body cannot choose who gets messaged or what they are told, so a leaked secret buys at
most a truthful update about a real ticket sent to the person who already owns it. The
outcome is read from the approval record first, because an approved and a declined
request both end up Resolved; a self-withdrawal is checked before a decline, because
Jira reuses the Declined resolution for one.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.
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.
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.
| 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 |