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.
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.
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:
Apps.Users.Read (shown as Read app accounts in the UI)People.ReadWorkflows.ReadWorkflows.Runs.ReadAuditLog.ReadUsers.ReadDo 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:
SLACK_USER_CAPABILITIES_JSON./diag and authenticated /diag/upstream
report exactly the expected enabled scope set.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:
Authorization requires the applicable checks to pass:
SLACK_ALLOWED_TEAM_IDS.SLACK_ALLOWED_API_APP_IDS.SLACK_ALLOWED_USER_IDS and a valid role in
SLACK_USER_ACCESS_JSON.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.
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_manifest.yaml
in the target workspace. The manifest requests chat:write, im:history, and
files:write; reinstall the app after adding files:write.xoxb-... bot token and signing secret in Secret Manager; do not put them
in .env.prod-env.yaml copied from
prod-env.yaml.example.deploy.ps1.https://<service>/slack/events, then subscribe only to message.im.For Hillspire Enterprise Grid, add a second Slack-native authorization boundary:
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.
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.
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.
GET / - liveness only.GET /diag - non-secret readiness posture and configuration issue names.GET /diag/upstream with X-Diag-Token - OAuth connectivity and granted scope names;
no SaaS Manager records are returned.GET /diag/llm with X-Diag-Token - one-word Claude probe.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.
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.
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.
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.
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.
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.