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/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.
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.
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:
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.
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_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 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.
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.
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.
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.