CISO as a Service. An AI vCISO agent that lives in Slack and Microsoft Teams, finds and drives your open security issues to closure, and runs your security program checklist to audit readiness — plus the console your team looks at every morning.
Domain: ciso.express
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ Marketing site │ │ Operator console │ │ Slack / Teams │
│ Next.js (web) │ │ Vite + React (app) │ │ people, by role │
└────────┬────────┘ └───────────┬──────────┘ └─────────┬──────────┘
│ signup + console │ REST + chat │ webhooks
└──────────────┬──────────────┴───────────────────────────────┘
▼
┌───────────────────────────────┐
│ Agent microservice │
│ Fastify + TypeScript │
│ │
│ • triage engine (rules) │
│ • LLM for wording/judgement │
│ • Slack + Teams adapters │
│ • scheduler + escalation │
│ • evidence collector │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ @cisoexpress/shared │
│ domain model · role matrix │
│ 90-control catalog · scoring │
│ playbooks · reducers │
└───────────────────────────────┘
What it actually does
1. It finds work and assigns it to the right person by role. Every issue has a category (vulnerability, access, identity, data protection, vendor, infrastructure, incident, process, compliance, training, asset) and every category maps to an escalation ladder of roles. A public S3 bucket goes to the DevOps engineer, then the engineering manager, then the CTO, then the CEO. An MFA gap goes to IT, then security engineering, then the CISO. A DPA gap goes to legal.
2. It reaches out — specifically. No "you have a ticket" pings. The agent writes the way a good human vCISO writes: what is wrong, why it matters, exactly what is needed, by when, and what happens next.
Hi Dana — critical: production snapshots are unencrypted. 24 hour SLA on this one. Can you confirm who can turn on snapshot encryption today?
3. It interprets the reply. "done, deployed to prod at 14:20" closes the issue and files the attestation against the linked controls. "blocked, I need change approval" marks it blocked, keeps the SLA clock running and offers to go get the approval. "not mine" reassigns it. "I'll do it Monday" logs a commitment. Questions get answered from the live register.
4. It escalates on its own, and stops when a human engages. Escalation level is computed from unanswered outreach since the last human reply, so a person who responds is never punished by climbing the ladder. Blocked controls escalate to the owner's manager, because a blocked control stalls the whole framework.
5. It runs the program checklist. 90 controls across SOC 2, ISO 27001:2022, NIST CSF 2.0, GDPR, HIPAA and PCI DSS. Each one carries the role that owns it, whether the agent can collect the evidence on its own, and the exact question it puts to a human when it cannot. Readiness is weighted by control priority, so a missing priority-1 control costs more than three maturity items — the number moves slowly and honestly, which is the point.
6. It reports like a security leader. A weekly digest that leads with one number and one grade, names the three things that need a decision, separates what the agent handled from what needs a human, and never lists thirty controls at a CEO.
Repository layout
cisoexpress/
├── packages/shared/ # domain model, roles + escalation matrix, 90-control catalog,
│ # scoring, message playbooks, reducers, seed tenant, typed API client
├── services/agent/ # the agent microservice (Fastify)
│ └── src/
│ ├── agent/ # triage rules, outreach, inbound interpretation, tools, chat, digest
│ ├── integrations/ # Slack, Microsoft Teams, email adapters
│ ├── tenancy/ # registry, request auth, per-tenant credentials, workspace cache
│ ├── routes/ # tenant API, platform (operator) API, webhooks
│ ├── server.ts # raw-body capture, auth hook, static console
│ ├── scheduler.ts # per-workspace sweep cadence
│ └── __tests__/ # decision engine + tenancy (isolation, auth, caching) suites
├── apps/app/ # operator console (Vite + React + Tailwind v4)
├── apps/web/ # marketing site (Next.js 16)
├── docs/architecture.md # design decisions and extension points
└── docker-compose.yml
Quick start
npm install
npm run dev # agent :8787, console :5173, site :3000 — all three at once
Open http://localhost:5173. It boots with a realistic demo tenant (Helios Robotics, ~180 people, 17 issues, 14 past SLA, 2 blocked controls) so you can see the product without any credentials.
Nothing is required to run it. With no environment at all:
| Capability | Behaviour with no config |
|---|---|
| Messaging | Messages are composed, logged and scheduled but not delivered (SLACK_LIVE=false) |
| Language model | The deterministic rule engine answers. Chat, triage, escalation and SLAs all work |
| Console | Full live mode against the in-process agent |
| Console without the agent | Falls back to running the same domain engine in the browser, so the UI is never dead |
Two starting states, on purpose. In development the store boots with the demo tenant so you can see the whole
product immediately. In production (NODE_ENV=production) an empty store instead creates a clean tenant: your
organization name, the full control catalog with an owner per control, an empty issue register, autonomy set to
suggest only, and one onboarding message. That way a real deployment can never hand a customer another company's
incidents. Configure it with CISOEXPRESS_ORG_NAME, CISOEXPRESS_ORG_DOMAIN and CISOEXPRESS_FRAMEWORKS.
Other entry points:
npm run dev:agent # agent only
npm run dev:app # console only (proxies /api to :8787)
npm run dev:web # marketing site only
npm run seed # reset the workspace to the demo tenant and print a summary
npm run typecheck # shared + agent + console
npm test # decision-engine test suite
npm run build # production build of both frontends
Connecting Slack and Microsoft Teams
Slack is the product's main surface, and customers install it themselves: one Slack app per
deployment, one install per workspace. docs/going-live.md has the app manifest and the
exact scopes; in short:
- Create the app from that manifest (it lists the redirect URL, the event subscriptions and the seven scopes).
- Put the client id, client secret and signing secret into the deployment —
terraform apply -var slack_client_id=... -var slack_client_secret=... -var slack_signing_secret=.... - In the console's Setup page the customer presses Connect Slack, authorises the app, and the bot token is stored sealed on their workspace. Nothing is shared between tenants except the app itself.
Every webhook is verified with that workspace's own signing secret (stored at install time) plus a 5-minute replay window, and the request is matched to a workspace by Slack's team id before any state is touched.
A self-hosted single-workspace deployment can still skip OAuth and set SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET and
SLACK_LIVE=true directly. Both integrations run in dry-run mode until they are live.
Microsoft Teams
- Create an Azure Bot resource and register the app; note the app id, password and tenant id.
- Messaging endpoint:
https://your-host/api/webhooks/teams/messages. - Set the credentials:
TEAMS_APP_ID=...
TEAMS_APP_PASSWORD=...
TEAMS_TENANT_ID=...
TEAMS_WEBHOOK_SECRET=... # shared secret required on inbound calls in this build
TEAMS_LIVE=true
Teams has an operational constraint worth knowing up front: the Bot Framework will not let you start a chat with someone who has never messaged the bot. The agent therefore stores a conversation reference the first time each person messages it, and until then it falls back to Slack or email for that person. That is why the integration status shows how many conversation references have been captured.
Language model
Any OpenAI-compatible endpoint, Anthropic, OpenRouter, Azure OpenAI or a local Ollama:
LLM_PROVIDER=anthropic # openai | anthropic | openrouter | azure_openai | ollama
LLM_API_KEY=sk-ant-...
LLM_MODEL=claude-sonnet-4-5
On boot the service probes the model once and reports the result. If a key is set but the model is unreachable, the console shows a banner and the agent runs on its rule engine instead of pretending everything is fine.
Autonomy
The agent's behaviour is rule-first by design: SLA arithmetic, escalation order and ownership come from deterministic code, because a CISO has to be able to explain and reproduce why the agent did something. The model is used for wording and for interpreting ambiguous replies.
| Setting | Agent behaviour |
|---|---|
suggest_only |
Composes messages and queues them. Nothing is delivered until released from the console. |
act_and_report |
Sends routine nudges and evidence requests, escalates on its own, reports weekly. Default. |
act_autonomously |
Same, with escalations sent without review. Only once you trust the routing. |
Guard rails that are always on: quiet hours per person's timezone (critical issues only break through at escalation level 2+), a hard cap on messages per sweep, one message per issue per follow-up window, and duplicate suppression.
Connectors
The agent's value is that it verifies instead of asking for screenshots. The catalog marks 41 of its 90 controls as machine-verifiable; connectors are what turn those into zero-conversation controls.
| Connector | Auth | Claims |
|---|---|---|
| GitHub | fine-grained read-only token | 13 controls |
| Okta | read-only API token | 13 controls |
| Microsoft Entra ID | app registration + admin consent | 15 controls |
| AWS | read-only role (or static keys) | 16 controls |
| Google Cloud | service account key, read-only scope | 20 controls |
| Microsoft Azure | Reader role on the subscription | 19 controls |
| Google Workspace | service account with domain-wide delegation | 8 controls |
Both major clouds and both major identity providers, because one of each would exclude half the market. Google Workspace deliberately overlaps Okta and Entra ID: a customer has one identity provider, not three, and the coverage numbers below only count a control once.
Six of these connectors also supply the roster: Okta, Entra ID, Google Workspace and Slack each enumerate
people, so the agent knows who runs IT before it asks them for anything. Reconciliation matches on the directory's
own id before email, keeps a human's role correction over its own guess, and will only deactivate somebody when the
read actually completed — see docs/going-live.md.
Between them they cover 26 of the 41 machine-verifiable controls (63%), and they check what auditors
actually sample: organisation-wide 2FA and who has not enrolled, branch protection on every default branch,
committed secrets, vulnerable dependencies, whether the sign-on or conditional access policy requires a
factor, privileged and dormant accounts, guest accounts with standing access, unmanaged devices, public
buckets and public IAM bindings, unencrypted or internet-reachable databases, missing audit logging, and
security group or firewall rules open to the internet on SSH or a database port.
npm run coverage # what is covered, by framework and domain
npm run coverage -- --gaps # the machine-verifiable controls still uncovered, and why
Adding one is a single interface: return findings (what is wrong), evidence (what is already fine, with
a validity window), and warnings (what you could not check) — see
docs/connectors.md, which also ranks what to build next based on the gap
analysis. Connectors are strictly read-only: the agent recommends, the customer's own tooling acts.
Two writers, one document
The API service and the scheduled sweep are separate processes and both write the same tenant registry and workspace documents. That is safe by construction rather than by luck:
- every write is version-checked (
ifGenerationMatchon GCS, a revision token on disk), so a stale writer finds out instead of silently overwriting; - a writer that loses the race replays its pending changes onto the reloaded document, so both sets survive;
- counters are incremented, never replaced — which is why usage recorded by the service and a sweep mark recorded by the job can land in the same document seconds apart.
That last point is not theoretical: an early version wrote whole documents from an in-memory copy, and a live
deployment showed the registry alternating between two sizes as each process erased the other's tenant. The fix is
covered by services/agent/src/__tests__/concurrency.test.ts, and the incident is written up in
docs/architecture.md.
Multi-tenancy
One process serves many customer workspaces. The slug is the primary key: it is immutable, url-safe, and addresses the workspace in three ways.
| How | Example | Use |
|---|---|---|
X-Tenant header |
X-Tenant: acme |
API clients and the console |
| Subdomain | https://acme.ciso.express/api/bootstrap |
Browser sessions, no header needed |
| Platform operator key | X-API-Key: <admin> + X-Tenant: acme |
Support, provisioning, inspection |
Provisioning a workspace
The marketing site creates workspaces through POST /api/signup, which is public and therefore rate limited,
never takes a slug from the caller, and always provisions a clean workspace. The operator key can create them too,
with more control:
# Create it (the API key is returned exactly once)
curl -X POST https://api.ciso.express/api/platform/tenants \
-H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
-d '{"slug":"acme","name":"Acme Freight","domain":"acmefreight.com",
"plan":"growth","frameworks":["soc2","iso27001"]}'
# Read it back later (no key material is ever returned again)
curl https://api.ciso.express/api/platform/stats -H "X-API-Key: $ADMIN_API_KEY"
Hand the customer their key, and they open https://app.ciso.express/?tenant=acme&key=ce_live_…. The console strips
the key from the address bar and stores it locally.
What is isolated
- Workspace state — a separate document per tenant (
data/tenants/<slug>/workspace.json), loaded on demand into an LRU cache and flushed to disk before eviction. - Credentials — a customer can bring their own Slack app, Azure Bot and model key. Stored on the tenant record,
sealed with AES-256-GCM under
TENANT_SECRET_KEY. The API returns "has a token" booleans, never the values. - Limits — message caps and sweep cadence come from the workspace's plan, so a trial cannot spend an enterprise's budget.
- Auth — a workspace key can only ever reach its own workspace. Passing
X-Tenantfor another workspace is a 403, not a redirect. Platform operators are the only cross-workspace callers. - Webhooks — Slack
team_idand the Azure tenant id map inbound events to the right workspace, and the request is verified with that workspace's signing secret before anything is touched. An event that cannot be attributed is acknowledged and dropped rather than guessed at.
Two starting states, on purpose
| Condition | Result |
|---|---|
| Development, empty store | The demo tenant (Helios Robotics, 17 issues, 14 past SLA) |
| Production, empty store | A clean workspace: your org, the full catalog with an owner per control, no incidents, suggest only autonomy, one onboarding message |
seedDemoData: true on provision |
A sales sandbox: the sample incidents, but named after the customer |
A production boot can never hand a customer another company's incidents — that is why the clean-tenant factory exists and why the polarity of that decision is asserted in the test suite.
Deployment
Google Cloud (recommended, Terraform included): Cloud Run for the agent, Cloud Storage for workspace state, Secret Manager for credentials, and a scheduled Cloud Run Job for the sweep. No Kubernetes.
cd deploy/terraform
terraform apply -var project_id=<project> -target=google_project_service.enabled \
-target=google_artifact_registry_repository.images
docker buildx build --platform linux/amd64 -f services/agent/Dockerfile \
-t "$(terraform output -raw image_repository):v0.2.2" --push .
terraform apply -var project_id=<project> -var image_tag=v0.2.2
Full walkthrough, cost estimates, DNS for acme.ciso.express, secret rotation and troubleshooting:
deploy/terraform/README.md. A GitHub Actions workflow
(.github/workflows/deploy-cloud-run.yml) rolls revisions with Workload Identity Federation — no service account keys.
Two Cloud Run details worth knowing before you read the Terraform:
- Workspace state lives in Cloud Storage, not on the container's filesystem, which is ephemeral. The storage
driver is selected with
STORE=gcs; object writes are atomic, and every write is version-checked so the service and the sweep job can both write the same document without losing each other's changes. - The background sweep is a Cloud Run Job, triggered by Cloud Scheduler, so the service itself needs no idle CPU
and each workspace's cadence is stored on its record rather than in a process.
max_instance_count = 1is deliberate; see the scaling section of the Terraform README.
Single container (agent serves the built console at /console/):
docker compose up --build
# agent + console on :8787, workspace persisted to the ciso-data volume
Split deployment (the recommended production shape):
| Piece | Where | Notes |
|---|---|---|
apps/web |
Vercel | Static. Set NEXT_PUBLIC_API_URL to the agent's public URL for the signup form, and NEXT_PUBLIC_CONSOLE_URL if the console is not served by that same origin. |
apps/app |
Vercel / Netlify / S3+CDN | npm run build --workspace @cisoexpress/app. Set VITE_AGENT_URL at build time. |
services/agent |
Fly.io / Render / ECS / any container host | Necessarily long-lived: it holds the scheduler and receives Slack/Teams webhooks. |
Environment for a hardened multi-tenant deployment:
ADMIN_API_KEY=<openssl rand -base64 32> # platform operator: provisions and manages workspaces
TENANT_SECRET_KEY=<openssl rand -base64 32> # encrypts customer credentials at rest
PLATFORM_DOMAIN=ciso.express # enables acme.ciso.express style routing
DATA_DIR=/data # registry plus one directory per workspace
CORS_ORIGINS=https://app.ciso.express,https://ciso.express
TENANT_CACHE_SIZE=50 # workspaces held in memory
Without TENANT_SECRET_KEY in production the service refuses to store customer credentials rather than writing
them in plaintext — the platform workspace keeps working from the environment.
Data and security posture of the service itself
- No customer data is sent to the model beyond what is needed. Prompts contain issue metadata, control titles and the human's message — never file contents, secrets or credentials.
- Secrets live in the environment, never in the workspace store. The console displays only which secrets are present, never their values.
- Every mutation is attributed. Each state change writes an activity event with the actor, the reasoning and the channel, which is what makes the audit trail defensible.
- Least privilege by default. Slack scopes are the minimum set for DMs and directory reads; cloud and IdP connections are read-only.
- Storage is a single JSON document behind a
StoreDriverinterface (services/agent/src/store.ts) so Postgres can be dropped in without touching the agent or the HTTP layer.
Brand
The mark — a shield for security, a chevron for the "express" in the name, and an emerald dot for the agent
being permanently on watch — is defined once in packages/shared/src/brand.ts. The marketing site, the console
and both favicons all render that one definition, and a unit test fails if a committed favicon drifts from it.
npm run brand:icons # regenerate apps/*/public/icon.svg from the definition
npm run brand:preview # render the mark at 16-128px on dark and light, plus the lockup
The geometry was chosen by rendering variants side by side at 16, 24, 32 and 64 pixels. A double chevron looked better on paper and turned to mush in a browser tab; a single bold chevron plus the status dot is what survived. The chevron is painted in the background colour so it reads as a cut-out and stays crisp at favicon size.
Testing
npm test # unit suites: the decision engine, tenancy, connectors
npm run test:e2e # browser suite: marketing site + console against a real agent
npm run test:e2e:app # console only (provisions its own workspace each run)
npm run screens # screenshots of every page, using the same Chromium
npm run screens writes PNGs of the marketing site and the console (in demo mode, which is the populated
sample company) to a directory of your choice — useful for a deck, a README or a design review without
opening a browser.
The unit suites cover the parts that must never break: SLA and risk arithmetic, role-based routing, escalation behaviour, tenant isolation and cross-tenant refusal, credential sealing, optimistic concurrency between the service and the sweep job, connector mappings, and the AWS signature implementation — that last one verified against a signature produced by botocore rather than against its own output.
The browser suite starts the agent, the console and the marketing site, provisions a workspace through the platform API, and then drives a real browser through signing in, the dashboard, the program checklist, connectors, the agent chat, the Setup page and the signup — including following the signup through to the console and reading the new workspace back out through the operator API, so a passing run means the whole stack works rather than that the components rendered.
Docs
docs/going-live.md— the runbook: create the Slack app, connect a directory, start the program, and what to add before pointing real traffic at signup.docs/architecture.md— how the pieces fit, why the decisions are rule-first, and where to extend (new frameworks, new integrations, Postgres).
Status
Working product with a complete agent loop, complete console and complete marketing site, running multi-tenant with per-workspace credentials, limits and isolated state.
Deliberately not included yet: verified email on signup and a captcha in front of it (the endpoint is rate limited
in-process, which is right for one instance and not for a fleet), per-user accounts (a workspace key is the credential
today, so there is no SSO or RBAC inside a workspace), a Postgres driver (the keyed WorkspaceStoreDriver interface is
ready for it), and object storage for uploaded evidence.
Live against real tenants: GitHub, Okta, Entra ID, AWS, Google Cloud and Azure. The Google Workspace connector and the Entra ID manager resolution are written and unit-tested against recorded API shapes but have not been run against a real tenant — the first sync should be read for warnings rather than trusted blindly.
Licensed under the terms in LICENSE. © 2026 CISO Express.