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/XLSX renderers -> 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.

Slack answer presentation

Carl uses one canonical, sanitized mrkdwn answer for the Slack DM, short-lived conversation context, and optional file export. Longer answers lead with a compact bold summary, group records under bold section labels, use real bullet glyphs, and reserve inline-code styling for opaque SaaS Manager record references. Important dates and vendor/application names are emphasized selectively; action and data-quality notes are kept short and separate.

Record-reference styling and bullet normalization happen deterministically after mention neutralization, so presentation does not weaken the untrusted-data boundary. Carl does not use arbitrary text colors—Slack does not expose them for mrkdwn, and inline-code colors remain controlled by each Slack client’s theme. The answer remains a normal text payload rather than a collection of Block Kit sections, avoiding per-block length limits and keeping the Slack/PDF representations aligned. This requires no additional Slack scope.

If a narrative answer exceeds 6,000 characters, Carl keeps the executive lead in Slack and automatically attaches the complete bounded answer as a PDF. This threshold is enforced after deterministic sanitization. If PDF generation/upload fails, Carl falls back to Slack text and states when that fallback had to be truncated.

Obvious exhaustive contract-report requests do not wait to cross that character threshold. They bypass conversational row generation entirely, fetch the bounded portfolio through the financial tool, keep only an aggregate executive summary in Slack, and automatically attach the full contract-level report as a paginated PDF.

Slack references:

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 narrative PDF format and explain that substitution. Carl exports only the latest sanitized answer belonging 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.

Only that answer is retained in the dedicated export cache, in process memory, for 30 minutes. The user’s question is not copied into the export cache or PDF. The generated PDF is also created in memory, bounded to 4 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.

Contract portfolio PDF and Excel export

For an exhaustive contract-by-contract request, Carl prepares a paginated PDF instead of trying to squeeze hundreds of rows through Claude or Slack. The PDF includes every matching contract’s vendor, reference, contract start date, renewal date, latest line-item end date, automatic-renewal flag, notice period, listed contract value, and priced/unpriced coverage. The date labels are deliberately distinct because the API does not expose an explicit contract end-date field.

When the caller explicitly asks for active or current contracts, Carl applies a conservative server-side filter before calculating totals or rendering any rows. A record must have started (or lack a start date) and either be rolling or have a renewal/latest line-item end signal on or after the report date. Records with past end signals, future start dates, or no reliable end signal are excluded and counted separately in the Slack summary and export. Requests for all executed contracts remain unfiltered.

When the caller explicitly asks for Excel or a spreadsheet, Carl creates a real .xlsx workbook instead. It has three styled sheets: an executive overview, contract totals by stored currency, and the underlying minimized line items with their unit counts and dates. Filters, frozen headers, column sizing, and numeric formatting make it immediately usable by Procurement/Finance.

Detailed rows never enter Claude’s context. The policy layer returns only aggregate counts, currency totals, calculation basis, and completeness to the model; it keeps the minimized rows as an in-process artifact for the selected renderer. Neither renderer accepts notes, document links, custom fields, internal IDs, or creator/modifier identities. The PDF is bounded to 4 MB. Excel strings are neutralized against formula injection, and the workbook contains no formulas, macros, or external links and is bounded to 8 MB. Both formats are generated in memory and uploaded only to the authenticated requester’s D... DM.

Enable both CARL_ENABLE_XLSX_EXPORT=true and CONTRACT_PORTFOLIO_EXPORT_ACKNOWLEDGED=true only after approving the wider Slack-retained data artifact. (CARL_ENABLE_XLSX_EXPORT is the original portfolio feature flag and gates both formats; PDF output also requires CARL_ENABLE_PDF_EXPORT=true.) A financial role is still required. The existing files:write permission is sufficient; no new Slack or SaaS Manager scope is needed.

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 sums each stored line-item amount/baseAmount exactly once, grouped by currency, and labels the result as contract commitment—not actual spend, invoices, or cash paid.

unitCount is reported separately and is not multiplied because the public API schema does not define amount as a per-unit rate.

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. Carl now reports counts of active accounts over 90 days since login and active accounts with unavailable login data as review signals, alongside the broader status/recency distribution. 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.

SaaS Manager’s own Savings tracker is the authoritative product view for the number and nominal value of licenses reclaimed by workflows or manual deprovisioning. The enabled public API scopes do not expose that report. A defensible dollar-savings feature therefore needs a supported Savings tracker API/export from 1Password or a controlled periodic report ingestion; Carl must not reverse-engineer it from run counts.

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.

Executed contract document delivery and Q&A

The official contract schema includes documents[].linkUrl under Contracts.Read, but source documents and links remain intentionally excluded from Carl. Direct delivery of an executed PDF to Slack is a separate feature from contract-body Q&A and should not involve Claude at all. Before enabling it, validate the live link hosts/authentication behavior, approve which pilot roles may retrieve documents, approve Slack retention of a new full contract copy, require exact-contract disambiguation, and enforce PDF MIME/magic/size checks with metadata-only logs.

Contract-body Q&A is a still larger phase because it adds parsing, prompt-injection, retention, and model-provider exposure. It requires document classification, per-role authorization, citation requirements, extraction controls, and separate legal/data-owner approval.