SysEngineering

Carl - SaaS Manager Slack Agent

Carl is a private, direct-message-only Slack agent for read-only questions about Hillspire’s 1Password SaaS Manager data (formerly Trelica). It reuses Greg’s proven shape—Slack Events API -> FastAPI -> Claude tool loop -> read-only backend—but replaces the Google Workspace layer with a deliberately narrow SaaS Manager client.

Carl answers questions such as:

It does not retrieve source contract PDFs, answer from pending Malbek reviews, change any SaaS Manager record, or represent contract commitment as actual spend.

Architecture

Slack App Home DM (message.im only)
  -> Cloud Run / FastAPI (main.py)
       signature + timestamp verification
       workspace + app + user allowlists
       per-user data role + independent capabilities; DM-only gate; event deduplication
       -> Claude tool loop (agent.py)
            -> policy boundary (saas_manager.py)
                 OAuth2 Client Credentials, exact read scopes
                 field minimization/redaction, URL allowlist, bounded pagination
                 -> 1Password SaaS Manager REST API
       -> optional in-memory PDF renderer -> Slack file upload to the same DM only

The LLM never receives the SaaS Manager client credentials or raw API response. Tool results are shaped and role-filtered first. API data is treated as untrusted content, not as instructions.

API scopes and feature gates

Carl’s baseline always requests exactly Apps.Read Contracts.Read. The expansion can use these additional read-only scopes when their matching gates are enabled:

Do not grant Assets.Read, any *.Write scope, Workflows.Runs.Execute, or Workflows.Runs.ReadSecrets. Every expansion gate defaults to false. While all are off, the runtime still requests only Apps.Read Contracts.Read, even if the OAuth client is configured with every approved read scope above. The token exchange always sends an explicit scope string, and Carl rejects a token whose returned scope set differs from the exact enabled set.

Feature Environment gate Trelica scope(s) Output boundary
Aggregate app activity SAAS_MANAGER_ENABLE_ACCOUNT_ACTIVITY Apps.Users.Read Counts by account status and login-age bucket; no identities/timestamps
Named-person apps SAAS_MANAGER_ENABLE_NAMED_PERSON_APPS plus NAMED_PERSON_DATA_EGRESS_ACKNOWLEDGED People.Read Work name/email/status, app association/status, login-age bucket; high-risk HR fields removed
Workflow inventory SAAS_MANAGER_ENABLE_WORKFLOW_INVENTORY Workflows.Read Workflow name, trigger type, step-type counts only
Workflow health SAAS_MANAGER_ENABLE_WORKFLOW_HEALTH plus WORKFLOW_RUN_DATA_RISK_ACKNOWLEDGED Workflows.Read, Workflows.Runs.Read Run status and age counts only; all run subjects/context removed
Audit summary SAAS_MANAGER_ENABLE_AUDIT_SUMMARY AuditLog.Read Event-type counts only
Console-user inventory SAAS_MANAGER_ENABLE_ADMIN_INVENTORY Users.Read Aggregate active/inactive counts only

The Apps.Users.Read response contains account names, emails, IDs, statuses, and login timestamps. Carl processes those records only inside its deterministic policy boundary and returns counts grouped by status and last-login recency. Individual identifiers and timestamps are never sent to Claude, Slack, or application logs. The tool requires both a financial role and the caller’s account_activity capability, plus an application search term; it cannot enumerate the portfolio in one request.

People.Read is materially more sensitive than its name implies: the raw person schema can include aliases, employee ID, manager, location, cost center, phone, personal home address, employment dates, teams, and custom fields. Named-person lookup removes all of those. It returns only the minimum work identity needed for safe disambiguation plus app association/status and a login-recency bucket. This capability remains closed until the data owner explicitly accepts that minimized work identity and app associations will be sent to Claude and returned in an approved user’s Slack DM.

Workflows.Runs.Read can return full onboarding/offboarding employee context even without Workflows.Runs.ReadSecrets. Carl never requests the secrets scope or a variables query parameter, and converts each raw run to aggregate status/age counts before model egress. The separate risk acknowledgement exists because a compromised service principal could still retrieve the raw run response before Carl minimizes it.

Safe rollout order for any expansion:

  1. Configure only the approved read scope(s) on the SaaS Manager API app and save it.
  2. Assign the capability to named pilot users in SLACK_USER_CAPABILITIES_JSON.
  3. Set only that feature’s service-wide gate. Set a risk acknowledgement only after its described approval is recorded.
  4. Deploy a 0%-traffic candidate. Verify /diag and authenticated /diag/upstream report exactly the expected enabled scope set.
  5. Test minimization with one narrow query, inspect Cloud logs for metadata-only logging, then manually promote. Enable the next feature in a separate revision.

SaaS Manager issues a Client ID and Client Secret, not one static API key. Save the secret immediately; SaaS Manager only shows it once. The service exchanges these for a short-lived bearer token and caches it only in memory.

Official references:

Access model

Authorization requires the applicable checks to pass:

  1. Valid Slack HMAC signature and timestamp within five minutes.
  2. Exact Slack workspace ID in SLACK_ALLOWED_TEAM_IDS.
  3. Exact Slack app ID in SLACK_ALLOWED_API_APP_IDS.
  4. Slack member ID in SLACK_ALLOWED_USER_IDS and a valid role in SLACK_USER_ACCESS_JSON.
  5. For every expansion tool, an explicit per-member capability in SLACK_USER_CAPABILITIES_JSON and its service-wide feature gate.

Roles are cumulative:

Role Data returned
inventory App name/instance, status, risk level, vendor, license-plan dates
contracts Inventory plus executed contract dates, vendor/reference, renewal/notice terms, line-item descriptions and quantities
financial Contracts plus line-item amounts/currencies, payment method, validated business-owner email, commitment summaries, and—when separately enabled—aggregate-only app account activity

Capabilities are non-cumulative: account_activity, named_person_apps, workflow_inventory, workflow_health, audit_summary, and admin_inventory. A financial role does not silently grant any of them. Every role excludes contract document bodies and links, notes, custom fields, creator/modifier identities, and external IDs. Financial users may receive the validated business-owner email recorded directly on a contract. Unauthorized users are silently ignored and receive no data.

Important Slack limitation: a workspace-installed app may still be discoverable in the workspace app directory. Slack does not provide a per-member “make this installed app invisible” control. The enforceable guarantees here are no channel presence, no channel events/scopes, silent denial, and server-side data authorization.

See SECURITY.md for the threat model and the stronger per-user OAuth option if native SaaS Manager identity enforcement becomes necessary.

PDF export

An authorized user can say export this as PDF after Carl answers a question. Requests such as put that answer in a Word document or save the last results to Excel produce the supported PDF format and explain that substitution. Carl exports only the latest sanitized answer already shown to that same Slack member in that same DM. It does not run a broader lookup, retrieve a contract document, accept a destination, or export another person’s conversation.

The exchange is retained only in process memory for 30 minutes. The generated PDF is also created in memory, bounded to 2 MB, uploaded directly to Slack, and never written to Cloud Run’s filesystem or application logs. Service restarts intentionally clear the export cache; the user must rerun the lookup if no recent answer is available.

The Slack app requires files:write. That Slack scope technically permits uploading, editing, and deleting files as the app, but Carl implements only the current external upload flow, uses Slack’s form-encoded ticket/completion requests, and supplies only the requester’s validated D... direct-message ID as the share destination. It does not request files:read and has no file delete/edit method. Keep CARL_ENABLE_PDF_EXPORT=false until the updated Slack app is reinstalled and the resulting bot token has been stored in Secret Manager.

Slack references:

Slack app setup

  1. Create or update the app from slack_app_manifest.yaml in the target workspace. The manifest requests chat:write, im:history, and files:write; reinstall the app after adding files:write.
  2. Store the xoxb-... bot token and signing secret in Secret Manager; do not put them in .env.
  3. Record the workspace team ID, new app ID, approved member IDs, role, and explicit capabilities for each user in a local prod-env.yaml copied from prod-env.yaml.example.
  4. Deploy the no-traffic candidate with deploy.ps1.
  5. In Slack Event Subscriptions, set the request URL to https://<service>/slack/events, then subscribe only to message.im.
  6. Confirm App Home -> Messages tab permits messages. Reinstall if Slack requests it.

For Hillspire Enterprise Grid, add a second Slack-native authorization boundary:

  1. Open Organization settings -> Integrations -> Installed apps -> Carl.
  2. Open Member Permissions.
  3. Set Who has permission to initiate interaction with this app to Specific people and groups and select only the four pilot members.

This platform control complements Carl’s server-side four-ID allowlist. It restricts who can initiate interaction, but it does not make an installed app invisible to workspace owners/admins or guarantee that its listing cannot be discovered. Do not install Carl at the organization level for this pilot: Slack documents that org-level installs notify Org Owners and Org Admins.

The manifest asks only for chat:write, im:history, and files:write. It does not request users, file-reading, app mentions, public channels, private channels, or multi-person DM access.

Secure pilot bootstrap

The local prod-env.yaml contains the exact Hillspire workspace/app/user allowlists and no credentials. After rotating the Slack values exposed during setup, run:

cd integrations\carl_contracts_agent
.\bootstrap-gcp.ps1

The script uses masked prompts to create/update the four Carl credential secrets, creates a random diagnostic token, reuses Greg’s gw-assistant-anthropic-api-key only for the pilot, and grants a dedicated Carl runtime identity access to just those six secrets. It does not place secret values in the repository, command arguments, or shell history.

This narrows Carl’s own identity, but the existing project still has a legacy default Compute Engine identity with project-wide Secret Manager access. See SECURITY.md; fully isolating Carl requires migrating that access or deploying Carl in a separate project.

Then run deploy.ps1. The deployment is pinned to the dedicated runtime identity and creates a no-traffic candidate. On the first deployment only, Cloud Run requires a live revision before it permits 0% traffic; the script therefore creates and verifies a kill-switched bootstrap revision that discards message events, then creates the enabled candidate at 0%. It never promotes that candidate automatically.

Local test

cd integrations\carl_contracts_agent
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -m unittest test_carl_contracts_agent -v

For a local Slack callback, use real secrets only through process environment injection:

op run --env-file .env.example -- .\.venv\Scripts\uvicorn.exe main:app --port 8080

ALLOW_UNVERIFIED_SLACK_REQUESTS=true is honored only with APP_ENV=local; production cannot disable request signatures through that flag.

Diagnostics

Logs record Slack actor ID, assigned role, event/request ID, tool name, success, and latency. They deliberately do not record question text, answers, API tokens, raw API payloads, or Slack API response bodies.

Known gaps

Actual spend

As of this build, the published SaaS Manager API scope catalog includes no Spend.Read scope and the published application API schema exposes applications, app accounts, assets, and contracts—not the Spend ledger/report. contract_commitment_summary therefore calculates only the listed line-item amount multiplied by unit count, grouped by currency, and labels the result as contract commitment—not actual spend, invoices, or cash paid.

Before promising “How much did we spend across our apps in 2026?”, confirm one of:

The automated S3 export is the strongest SaaS-Manager-sourced path currently documented, but it is closed beta and requires 1Password Support. Do not scrape UI endpoints or use an undocumented transaction endpoint. Carl needs an exported schema/sample before a read-only spend provider and its field-level policy can be implemented.

Utilization and license reclamation

The optional aggregate summary uses Apps.Users.Read, but that endpoint publishes only account status and last-login timestamps. It does not publish purchased-seat counts, license assignments, SaaS Manager engagement levels, cost-per-account, or reclaimable savings. Carl deliberately reports aggregate activity signals as counts. The separate named-person tool can answer app-association questions, but it still must not identify a person as a reclamation target or claim that an account equals a paid license. Richer optimization requires a separately approved, supported data source and privacy design.

Workflow runs

Workflows.Runs.Read is read-only but high sensitivity: raw run context can contain an employee’s name, email, employee ID, location, job title, team, employment dates, and custom fields. Carl’s tool returns only status/age counts, but the OAuth service principal can see the raw response in memory. Keep this feature off unless the data owner accepts that residual and the four-person pilot genuinely needs workflow health.

Audit and SaaS Manager console users

Carl currently supports aggregate posture only. It cannot answer “who changed this?” or list named SaaS Manager administrators. Actor attribution and named administrator output would be a separate security use case, with a narrower audience and explicit approval.

Contract document Q&A

Source contract PDFs and links are intentionally excluded. Enabling contract-body Q&A would materially expand confidentiality, prompt-injection, retention, and model-provider exposure. Treat that as a separate phase with document classification, per-role authorization, citation requirements, extraction controls, and legal/data-owner approval.