TriagoAI
PlatformFor MSPsIndustriesPricingSecurityTrustWhat's next
Sign inLive demo

Documentation

TriagoAI documentation

Everything you need to run, extend and evaluate TriagoAI - from the 7-agent model to integrations, security and API reference.

Getting StartedAgentsIntegrationsAPI ReferenceSecurityFAQ

Sections

  • Getting Started
  • Agents
  • Integrations
  • API Reference
  • Security
  • FAQ

Getting Started

Run, build, test and deploy TriagoAI — stack overview, commands, folder map, and deployment topology.

docs/developer.md

TriagoAI — Developer Guide

Everything a developer needs to run, build, test and navigate TriagoAI. For the

domain language see CONTEXT.md; for design/UI see docs/design.md; for known

issues and postmortems see docs/problem.md.

Stack

LayerChoice
FrameworkNext.js 16 (App Router)
UIReact 19, TypeScript 5, Tailwind CSS 4
DataPrisma 7 + SQLite (libsql adapter) + vectors.db RAG sidecar
Authjose JWT (HS256) session cookie
BillingStripe (mocked when keys absent)
Emailnodemailer (Ethereal test mode when no SMTP)
Testsvitest

Commands

npm run dev          # next dev -p 3004   → http://localhost:3004
npm run build        # next build
npm run lint         # eslint
npm test             # vitest run
npx tsc --noEmit     # typecheck
npx prisma migrate dev      # create + apply migration locally (also regenerates client)
npx prisma generate         # regenerate client to lib/generated/prisma

Folder map

triagoai/
├── prisma/               # schema.prisma + migrations/ (SQL)
├── lib/
│   ├── agents/           # runtime.ts + 7 agents (triage/diagnosis/remediation/…)
│   ├── actions/          # "use server" actions (tickets, fleet, admin, auth, knowledge, …)
│   ├── fleet.ts          # simulated 10-node SME fleet + remediation executor
│   ├── live-generator.ts # continuous SME incident flow (Live Mode)
│   ├── llm.ts            # mock | kimi | deepseek | openrouter providers
│   ├── tools.ts          # allowlisted tool registry (read/write/destructive) + chat tools
│   ├── audit.ts          # SHA-256 tamper-evident audit chain
│   ├── rbac.ts           # ITIL role → permission matrix
│   ├── login-security.ts # brute-force protection + account lockout
│   ├── kb-library.ts     # sourced ITIL/ITSM article + report seed
│   ├── markdown.tsx      # minimal markdown renderer
│   ├── generated/prisma/ # generated Prisma client (do not edit)
│   └── billing.ts / plans.ts / sla.ts / tickets.ts / sso.ts / …
├── app/
│   ├── (app)/            # authenticated console (queues, tickets, dashboards, admin, …)
│   ├── (marketing)/      # public pages (home, pricing, security, about)
│   ├── api/              # route handlers (ai/chat, auth/login, stripe/webhook, …)
│   ├── evidence/[id]/    # print-optimized DORA evidence pack (outside app shell)
│   └── middleware.ts     # CSRF origin check on authenticated /api/* mutations
├── components/           # ui.tsx kit, sidebar, chat-client, demo-console, forms
├── docs/                 # developer.md, design.md, problem.md, adr/, roadmap-90d.md
├── security/             # policies, audit-log-schema, pen-test-plan, vendor-risk
├── tests/                # vitest suite (tests/**/*.test.ts + lib/**/*.test.ts)
└── scripts/              # send-daily-reports.mts, backfill-kb-library.mjs, fine-tune.mjs

Test conventions

•vitest run with @ → repo-root alias (see vitest.config.mts).
•Integration tests share the local `prisma/dev.db` — fileParallelism: false

serializes files to avoid SQLite lock flakes.

•Tests create tenants with Date.now()-suffixed slugs and clean up in

afterAll (tenant delete cascades to children).

•Pure/DB-free helpers are preferred and unit-tested directly (e.g.

lib/password.ts, lib/login-security.ts, lib/markdown.tsx).

ECC dev harness

The adopted ECC engineering system is wired locally and in CI:

•npm run verify — the correctness gate (spec #80): lint (0 warnings) ·

typecheck · vitest · build · e2e · e2e:visual. One command that proves a change.

The Definition of Done and the evidence each stage produces live in

docs/agents/verification.md.

•npm run verify:ecc — the ECC gate (lib/ecc/verify.ts): typecheck · lint ·

security-scan · test; --full adds agent-eval and build.

•npm run ecc:dev-setup — installs the committed .githooks/pre-push, so every

push runs npm run verify:ecc.

•npm run verify:agentshield — advisory AgentShield scan of agent config.
•Rule packs (.opencode/rules/*.md) are injected into agent context by

lib/ecc/rules.ts. See CONTEXT.md (ECC, Rule pack, Verify gate, Engineering

pipeline, Instinct) and docs/adr/0005, docs/adr/0006.

Gotchas (environment)

•TLS is intercepted in this environment. The Dockerfile builder stage sets

NODE_TLS_REJECT_UNAUTHORIZED=0 (for npm ci + Prisma engine downloads) — the

runner stage does NOT, so production outbound calls verify certificates.

•Native binaries (@next/swc-*, lightningcss, @tailwindcss/oxide,

sharp) can get truncated on npm install over the intercepted TLS — re-copy

from a known-good node_modules if a build suddenly fails.

•Colima (Docker on macOS) — see docs/problem.md for the stale-cache and

port-binding gotchas. localhost:3004 is the container, not a local

next dev, unless you explicitly run npm run dev.

Schema changes (checklist)

1.Edit prisma/schema.prisma.
2.npx prisma migrate dev --name <name> (creates SQL migration + regenerates client).
3.npx prisma generate if the client didn't refresh.
4.Verify new delegate/fields exist under lib/generated/prisma.
5.Migrations apply automatically on container start (prisma migrate deploy in

the Dockerfile CMD).

Billing dunning

Tiered failed-payment recovery lives in lib/billing/dunning.ts (registered as

the hourly dunning job). On invoice.payment_failed the webhook classifies the

decline from charge.outcome.reason (fetched via the charge):

•Hard (stolen_card/lost_card/do_not_honor/pickup_card/fraudulent)

→ cancel immediately, no retries.

•Soft/unknown → tiered recovery: immediate notice + reminders on the tier's

retry days, then cancel. ENTERPRISE [1,3,7,14], PROFESSIONAL [3,7,14],

STARTER [3,7]. Stop rules clear the sequence the moment the invoice is paid

or the subscription is cancelled.

Manual Stripe config (one-time): enable retention coupons in the Stripe

Customer Portal (Settings → Billing → Customer portal → Subscriptions and tick

"Allow customers to apply promotion codes") so a retention discount can be

offered before a customer cancels. Coupons themselves are created in the Stripe

Dashboard (or via API) — no code changes required.

RMM/PSA integrations

•HaloPSA (primary) — lib/halopsa.ts, uses halopsa-rest. Register an app:

HaloPSA → Config → Integrations → Halo PSA API → Applications → New, auth

method Client ID and Secret, scope all, and set an "Agent to log in as"

(the app is scoped to that agent's team). Store per-tenant on the Tenant row

(haloEnabled, haloCompanyUrl, haloClientId, haloClientSecret). Smoke-test

with npm run verify:halopsa (HALO_CLIENT_ID/HALO_CLIENT_SECRET/

HALO_COMPANY_URL in env, or pass a tenant id). Ships disabled (gated on

haloEnabled).

•NinjaOne (secondary) — lib/ninjaone.ts. Same gating pattern on

ninjaOneEnabled; npm run verify:ninjaone.

Prospecting (Hunter.io)

lib/prospect-finder.ts finds/verifies MSP owner emails via the Hunter API

(gated on HUNTER_API_KEY, free tier 50 credits/mo). npm run enrich:prospects

parses local/gtm/targets-global.md, prioritizes the first ~25 domains, and

writes data/leads.csv (--limit=N, --domains=a.com,b.io for a smoke test).

Conventions

•Domain language is pinned in CONTEXT.md — match it in code/comments.
•Every mutating action writes to the tamper-evident audit chain via

lib/audit.ts audit().

•No raw secrets in code or docs — env-var names only; values live in .env.
•Service specs — before editing a service (billing, webhook, chat), read its

specs/<service>.md and run tests/spec-contract.test.ts (see AGENTS.md).

•Commit with a Conventional Commits message; run tsc + eslint + vitest

+ next build before pushing.

•Update the docs (docs/* and local/*) after each session — see the

"Documentation map" section in CONTEXT.md.

docs/deployment.md

TriagoAI — Deployment & Data Residency

TriagoAI is a single-container Next.js app backed by SQLite (libsql) with a

vectors.db RAG sidecar. This document covers the current single-region

deployment, the multi-region target, and self-hosting with local AI.

> See docs/adr/0001-sqlite-single-node.md for why SQLite single-node is

> deliberate for the showcase, and the trade-offs of the multi-region path.

1. Current — Hetzner (single region, eu-central)

The live reference instance runs on a Hetzner CX22 VPS in Falkenstein

(eu-central).

                        ┌───────────────────────────────┐
   Browser ──:80/3004──►│  Docker: triagoai (node:22)   │
                        │  next start -p 3000            │
                        │  ├─ dev.db      (SQLite)       │
                        │  ├─ vectors.db  (RAG sidecar)  │
                        │  └─ uploads/    (file library) │
                        └───────────────────────────────┘
                                   volume: opspilot-data → /app/data
•Deploy: docker compose up -d --build from a pinned git revision.
•Data: one named volume (opspilot-data) holds the SQLite DBs and uploads.
•Region: labelled by the REGION env var (default eu-central); every

tenant's Tenant.region field defaults to eu-central.

2. Multi-region — AWS / GCP / Azure

Multi-region is a deployment topology, not a code change. Each region runs the

same container; a global router pins a tenant to its home region.

                  ┌──────────────┐
   Browser ──────►│  Global DNS  │──(tenant→region map)──┐
                  │  / edge LB   │                        │
                  └──────────────┘                        ▼
                        │                    ┌───────────────────────────┐
                        │                    │ eu-central (ECS/EKS)      │
                        │                    │ triagoai container        │
                        │                    │ dev.db · vectors.db       │
                        └───────────────────►└───────────────────────────┘
                                             │ us-east (ECS/EKS)         │
                                             │ triagoai container        │
                                             │ dev.db · vectors.db       │
                                             └───────────────────────────┘
•AWS — ECS Fargate + EFS for the data volume, or EKS with a PersistentVolume.
•GCP — GKE + a PersistentVolumeClaim, or Cloud Run (stateless; pair with

Cloud SQL if you migrate off SQLite).

•Azure — AKS + Azure Files, or Azure Container Apps with a mounted share.

Key points (conceptual, not a runbook):

1.Pin tenants to a region. Tenant.region records where a tenant's data

lives; the router sends that tenant's traffic to its region. No tenant is

served from two regions at once (SQLite is single-writer — see ADR-0001).

2.Durable storage per region. Each region needs its own persistent volume

for dev.db + vectors.db + uploads.

3.Replication is out of scope. Cross-region failover requires moving off

single-writer SQLite to Postgres/Turso; that is a Phase-G follow-up, not

this milestone.

4.`REGION` labels the deployment. Set it per region so dashboards/audit

exports are self-describing.

3. Self-host — Docker Compose + local AI

For customers who need everything in-box (regulated industries, air-gapped

sites), TriagoAI runs standalone with Ollama for AI — no data leaves the box.

# .env (self-host)
LLM_PROVIDER=          # unused when tenants are aiMode=local
OLLAMA_BASE_URL=http://ollama:11434
REGION=eu-central
•Run the existing docker compose stack plus an ollama service.
•Set each tenant's AI mode to Local in Admin → AI; every agent call

then routes to the self-hosted Ollama instance instead of a cloud provider.

•With no cloud LLM keys set, the app is fully offline (mock embeddings +

local completions).

4. Data residency

Two labels answer "where does my data live?":

SignalMeaning
REGION (env var)The deployment's region (default eu-central on Hetzner). Set per region in a multi-region topology.
Tenant.region (DB field)Where *this tenant's* data lives. Defaults to eu-central; shown in Admin → Overview under the tenant slug.

In a multi-region deployment these stay in sync: a tenant provisioned in

us-east has region = "us-east" and is served by the us-east deployment.

The field exists now so the label is already present for audit and routing when

the multi-region topology is turned on.

Agents

The seven AI agents and their ITIL 4 mapping, the product architecture, and industry packs.

docs/design.md

TriagoAI — Design Guide

Product and visual design: the design system, the 7-agent/ITIL model, and the

industry-pack differences. Domain terminology lives in CONTEXT.md.

Design system — flat navy panels

A dark, flat navy UI driven by CSS custom properties in app/globals.css.

TokenUse
--color-accent / --color-accent-2Primary brand gradient (buttons, active nav, links)
--color-ink / --color-ink-dimText and muted text
--color-glass / --color-glass-borderCard/panel surfaces and borders
--color-panel / --color-panel-2Layered panels
--color-lineHairline dividers
--color-hover / --color-activeHover/active row states
--color-danger / --color-warning / --color-success / --color-infoStatus colors (+ -border / -bg variants)
--color-code-bgInline code / code block background
--glow-1, --font-headingGlow effects and heading font

Shared primitives live in components/ui.tsx: Card, CardHeader, Badge,

Button (variants: default/ghost/outline/danger/success), Input, Textarea,

Select, Label, Table (Th/Td), Empty. Reuse these rather than ad-hoc

Tailwind; map status/priority through STATUS_COLORS/PRIORITY_COLORS in

lib/utils.ts.

The 7 agents (ITIL 4 mapping)

Every agent run records an inspectable timeline (think → tool → approval →

result). This transparency + the SHA-256 audit chain is the core differentiator

vs ServiceNow's "Now Assist".

AgentITIL practiceHuman-in-the-loop
TriageService Desk + Incident Mgmt—
DiagnosisProblem Mgmt + Monitoring & Event—
RemediationIncident resolution + Change Enablement✅ approval required
Change RiskChange Enablement—
KnowledgeKnowledge Mgmt—
SLA GuardianService Level Mgmt + Continual Improvement—
CommsIncident communication—

Architecture

!TriagoAI Architecture

detect → triage → diagnose → remediate (human approval) → verify → document
                  └──────── tamper-evident audit chain (always on) ────────┘
•Multi-tenant — every table carries tenantId; the vector store searches

global + caller-tenant only.

•RBAC — ITIL roles (ADMIN → REQUESTER); destructive approval gated on

approveRemediation. Matrix at /roles, defined in lib/rbac.ts.

•LLM flexibility — mock (zero-key) / Kimi / DeepSeek / OpenRouter, plus a

self-hosted Ollama path (per-tenant aiMode).

Industry packs

SMEFinancial
Change approvalsingledual (SOX segregation of duties)
DORA classification—doraClass on tickets + evidence pack
Regulated-impact tagging—regulatedImpact
UIlean defaultscompliance fields surfaced

Key screens

RoutePurpose
/queuesITIL queues (all/mine/unassigned/major/requests/breaching) + type/status filters
/tickets/[id]ticket detail + agent timeline
/missionscompany-wide work as a phase Kanban (Draft → Committed → Executing → Verifying → Closed) with WIP limits
/investigationsinfrastructure investigation graph — what is connected to a ticket, and what fixed it last time
/demopresenter URL — one-click end-to-end demo console
/dashboardsweekly CSI dashboard (clickable widgets)
/majormajor-incident war room
/portalrequester self-service (catalog + KB)
/evidence/[id]print-optimized DORA/SOC 2 evidence pack
/admintenant admin (integrations, AI mode, layout, webhooks, reports, errors)

See the ITIL-mapping table in README.md for the full practice → screen map.

---

Command centre — operator design (2026-09)

The internal command centre (command/dashboard.mjs, served at agents.triagoai.io

behind Basic Auth) is a separate application from the product UI. It was

rebuilt in Sept 2026 and its design is the reference for a calm dark-ops

surface.

Palette — dark-ops, not neon

The previous palette was neon (cyan/magenta/acid-green on near-black), which read

as a demo rather than a tool. The current one:

TokenValueUse
--bg#0b0f14page
--surface / --surface2#11161d / #161c24flat panels, no glass/blur
--line#1f2630neutral hairline borders
--ink / --dim / --dim2#e6eaf0 / #9aa4b2 / #6b7684text hierarchy
--blue (--cyan)#3b82f6the single accent
--green / --amber / --red / --pink#22c55e / #f59e0b / #ef4444 / #8b5cf6status only

Rules: one accent, status colours carry meaning and are never decorative, flat

surfaces (no backdrop-filter, no per-panel glow), and dark ink on light paper for

*documents* (below).

Information architecture — one nav model, a rail, and a graph workspace

Nav model:   lib/nav-model.mjs — the single source the sidebar, the palette and
             the mobile drawer all read (Now · Agents · Growth · Ops)
Desktop:     icon rail by default, hover shows the label, a toggle expands the tree
Mobile:      the sidebar BECOMES the drawer (hamburger + scrim); no bottom tab bar
Top bar:     ☰ menu   ⇤ rail toggle   title   ✉ Chat   ⌕ Search   🔔 Notifications
Workspace:   /graph — a 2D force graph of the company (Director → departments →
             skills/SOPs, memory, knowledge hubs, milestones); /workspace and
             /mindmap 302 to it
Aliases:     /sops → /library · /report → /reports (302)
Everything:  reachable via the command palette (⌘K search, ⌘O switch)

The hardcoded four-tab bar is gone: it was a second source of truth that could drift

from the sidebar, and it hid seventeen tabs. Regrouping is safe; a tab is removed only

when the ticket that owns its replacement lands. assertNavModel() enforces that

every tab sits in exactly one group and every redirect points at a real tab.

(Adopted from our own agents/docs/obsidian-ux-research.md and graph-view-research.md:

palette, switcher, force-layout graph view, minimal chrome.)

Decision surfaces

•"Needs you" — one inbox: missions awaiting pre- or final approval, and the

daily plan awaiting approval, each with inline approve / request-changes / reject.

A decision surface, not a list.

•Delegation panel (/agents) — per-agent done/failed/vetoed, verified pass

rate, tool failures, and description proposals. It states plainly that *"done

without verification is work nobody checked"* and shows no flattering 100% when

there is nothing to measure.

Documents vs dashboards

Progress on a mission is a hill chart (Shape Up: uphill = being figured out,

downhill = being understood), not a percentage — a linear bar implies uniform

progress and hides the risky half. Mission reports are *documents*: light paper

(#fafaf9), slate ink (#0f172a), serif headings, emerald accent — deliberately

distinct from the dark operator chrome, because a document is read and a dashboard

is scanned.

Design rules learned here

1.Never invent a status. An agent that has never run reads "Dormant", not a

reassuring idle dot. Every number comes from a table.

2.Say when nothing is measured. Empty states explain the mechanism

("the reconciler fills these", "the suite runs Mondays") rather than showing 0.

3.A declaration nothing reads is decoration — the reason tool risk metadata is

consumed, not just declared (ADR-0008).

4.Mobile is a first-class surface: bottom-sheet detail panels, scrollable

tables, 40px touch targets, env(safe-area-inset-bottom), tables that scroll

instead of cramming.

Integrations

External services TriagoAI can connect to — LLM providers, Microsoft 365, WorkOS SSO/SCIM, Stripe, and SMTP.

security/vendor-risk.md

TriagoAI — Third-Party / Vendor Risk Assessment

This is a design-time risk register for the external services TriagoAI can use.

Every integration is optional and off by default; the local-AI and mock

modes are the zero-sharing fallbacks.

VendorData sharedWhat the vendor can accessMitigations
DeepSeek (LLM)Triage/diagnosis/chat prompts and responses (ticket text, telemetry, KB snippets)The text sent to their APIOn by key only; UNTRUSTED_DATA_BOUNDARY prompt-injection guard; per-tenant aiMode=local routes to Ollama instead; text is truncated before send.
OpenRouter (LLM gateway)Same as above, plus model routing metadataThe text forwarded to the underlying modelSame as DeepSeek; provider is pluggable; local mode bypasses it entirely.
Ollama / local modeNothing externalNothing — self-hosted; data never leaves the boxDefault for aiMode=local tenants; no API key, no egress.
Microsoft 365 (Graph)Inbound email (from/subject/body) for ticket creation; outbound auto-replyEmail content for the configured mailboxOptional (M365_* env vars); mock mode by default; attachment content is not processed (metadata only).
WorkOS (SSO/SCIM)User identity (email, name, role) for directory syncDirectory user attributesOptional (WORKOS_*); mock mode by default; SCIM roles are allowlisted; SSO users have no local password.
StripeBilling (plan, seats)Billing metadataOptional (STRIPE_*); test-mode keys only; no card data touches TriagoAI.
SMTP / EtherealAlert + error-report emailsEmail body contentOptional (SMTP_*); defaults to an Ethereal test account.

Key controls

1.Default-deny. No external service is contacted unless its credentials are

present. Absent keys → deterministic mock, so the demo runs with zero egress.

2.Bring-your-own-model. The provider abstraction (lib/llm.ts) resolves to

DeepSeek/OpenRouter/Kimi or a self-hosted Ollama endpoint; a tenant's

aiMode forces the local path so no prompts leave the customer's network.

3.Least data. Payloads are truncated before sending, attachments are not

ingested, and the audit/prompt boundary strips instructions from untrusted

input (UNTRUSTED_DATA_BOUNDARY).

4.No secrets in logs. API keys, the session secret and SSH keys are

environment-only and never written to logs, audit payloads, or client output.

API Reference

Inbound webhook intake and the audit-log export API — endpoints, authentication, and payload formats.

Open the interactive API reference

docs/adr/0004-inbound-webhook-auth.md

Per-tenant bearer tokens for inbound webhooks

TriagoAI's inbound webhook intake (POST /api/webhooks/inbound) authenticates callers with a per-tenant bearer token: the tenant's webhookSecret field, generated on enable and rotatable from Admin → Webhooks. The token is sent as Authorization: Bearer <secret> and validated by direct DB lookup — no JWT, no shared infrastructure secret.

Alternatives considered:

•Shared secret in an env var — simpler to implement (one secret for all tenants), but any leak of the env var compromises every tenant simultaneously and makes rotation a cross-tenant incident. Rejected because multi-tenancy is a core design axis (every table already has tenantId) and the cost of per-tenant secrets is one field on Tenant plus one DB lookup on intake.
•Signed JWT with a tenant-scoped key — more flexible (expiry, scoped permissions), but introduces key management (storing a signing key per tenant, token lifecycle) for a use-case whose permission model is binary: "this monitoring tool is allowed to create incidents for this tenant." A simple bearer comparison is correct for that scope and avoids client-side JWT tooling requirements for monitoring operators.

Consequences:

•The secret is stored in plaintext in the Tenant.webhookSecret column so it can be compared on intake. This is acceptable because (a) the API is design-time-demonstrable (not a production deployment), and (b) the secret is random (24 bytes of hex) and scoped to a single tenant; a DB read-incident at worst compromises one tenant's webhook intake.
•Rotating the secret invalidates every existing integration immediately (displayed in the admin UI). Future: a dual-key rotation window (accept both old and new for ~5 minutes) would smooth the rotation but adds state; deferred until multiple external integrations exist per tenant.
•The token appears in the admin UI and the generated curl example — an admin is trusted to view it, but future hardening could hash the stored secret and show only an obfuscated prefix after first reveal.
•The bearer check is centralized in lib/webhook-auth.ts (bearerToken / resolveWebhookTenant) and always requires webhooksEnabled. Every inbound route (generic webhook, Datadog, PagerDuty, GitHub, Prometheus, NinjaOne, email intake, SCIM) crosses that one seam. Before centralization two routes omitted the webhooksEnabled guard, so a retained secret still authenticated after webhooks were nominally disabled; that divergence is fixed.

Status: accepted.

security/audit-log-schema.md

TriagoAI Audit Log — Schema & Tamper-Evidence

This documents the append-only audit log that underpins the Financial pack's

compliance story (SOX / DORA alignment) and is the primary artefact for SOC 2

evidence collection.

1. Event types logged

Every mutating action writes an AuditEvent. Representative actions:

DomainActions
Authauth.login, auth.logout, sso.enable, sso.disable, scim.user.provision, scim.user.deprovision
Ticketsticket.create, ticket.status.change, ticket.comment.email
Agentsagent.run, agent.remediation.execute (approvals are captured inside the run's approval step)
Fleetfleet.fault.inject, fleet.fault.clear, fleet.remediate, fleet.tick
Adminadmin.pack.change, admin.aiMode.change, admin.reset, webhooks.enable/disable, sso.*
Importtenant.bootstrap, demo.seed, importer actions

2. Schema fields

FieldTypeMeaning
idcuidEvent identifier
tenantIdstringMulti-tenant boundary (every query filters on it)
actorIdstring | nullUser id (null = system/agent/webhook)
actorNamestringHuman-readable actor (agent:DIAGNOSIS, webhook, scim, …)
actionstringNamespaced action verb (e.g. ticket.status.change)
entitystringTarget type (e.g. ticket, serverNode, user)
entityIdstringTarget id
payloadstringJSON of what changed
prevHashstringHash of the previous event ("" for genesis)
hashstringThis event's hash
createdAtdatetimeWall-clock timestamp

3. Tamper-evidence mechanism

Each event's hash is:

hash = SHA-256(prevHash + payload + action + entityId)

Because hash binds to the previous event's hash, the whole chain is

transitively linked: altering *any* field of *any* event changes its hash, which

changes the prevHash of the next event, and so on. Verification walks the

chain from genesis and compares each stored prevHash/hash against the

recomputed value — a single mismatch marks the chain broken at that event.

The export endpoint (/api/admin/audit/export) re-verifies the chain on every

export and stamps the document with a verified boolean and brokenAt id.

4. Retention & access controls

•Retention — events are retained for the tenant's lifetime (demo default;

see the retention policy in security/policies.md).

•Read access — the Audit Log page is visible to authenticated workspace

members; verification and export are admin-only (can(user.role, "admin")).

•Write access — none. There is no update/delete path for AuditEvent in

the application; rows are only ever created via the audit() helper.

5. Export format

•JSON (canonical) — { exportedAt, verified, brokenAt, eventCount, events[] }.
•CSV — a leading metadata line (# verified=… brokenAt=… eventCount=…) then

a header row and one row per event (all ten fields).

Both are produced by lib/audit-export.ts and served with

Content-Disposition: attachment.

Security

Security policy, access control, data classification, and the penetration-test plan.

security/policies.md

TriagoAI Security Policy

This document records the security posture for the TriagoAI showcase. It is

written to be reviewed by a technical CTO or security assessor ahead of a SOC 2

Type I engagement. Where a control is marked demo, it reflects the current

single-node showcase deployment; production hardening is noted inline.

1. Access control

•RBAC model. ITIL 4 practice-aligned roles (ADMIN, SERVICE_DESK,

INCIDENT_MANAGER, PROBLEM_MANAGER, CHANGE_MANAGER, CAB,

SERVICE_OWNER, REQUESTER). Every server action is gated through

can(role, permission); there is no ambient trust.

•Least privilege. Agents run with the *initiating actor's* context for

audit, and destructive tools (restart, clear-disk, failover, rollback,

password reset) can never auto-execute — they park behind a human approval

gate gated on approveRemediation.

•Session management. Signed JWTs (HS256) in an httpOnly cookie, 7-day

expiry, SameSite=Strict. Secure is opt-in (COOKIE_SECURE) so the demo works

over HTTP; production must enable it.

•Brute-force protection. Login and signup go through JSON route handlers

(POST /api/auth/login, POST /api/auth/signup) with persistent rate limits

and lockout: max 5 failed logins per IP per 15 minutes, max 10 failed logins

per account per hour, and a 15-minute account lockout after 5 consecutive

failures (tracked via User.failedLoginCount/User.lockedUntil). Every

attempt is recorded in LoginAttempt (email, IP, success, reason) — including

unknown-email probes. Exceeding a limit returns 429 with a retry hint.

•Login audit. Every successful (auth.login) and failed

(auth.login.failed, for known accounts) login is written to the

tamper-evident audit chain with the source IP and user-agent. Unknown-account

failures are recorded in LoginAttempt only (no tenant chain to attach to).

•CSRF. SameSite=Strict on the session cookie is the primary control.

Defense-in-depth: a middleware.ts rejects cross-origin Origin/Referer

mismatches on cookie-authenticated /api/* mutations, and the login/signup

handlers validate the Origin header against the request host. Server-to-

server endpoints (Stripe webhook, inbound webhooks, email intake, SCIM) are

exempt — they authenticate via signature/bearer tokens, not cookies.

•Multi-tenancy. Every table carries a tenantId; all queries filter on it.

The vector store searches only global + the caller's tenant — cross-tenant

leakage is prevented by construction (and asserted in tests).

•SSO. SAML/OIDC via WorkOS (mock by default). SSO users have no local

password (random non-login hash) and authenticate via the IdP.

2. Data classification

ClassExamplesHandling
Tenant datatickets, comments, CMDB, KB, usersDisk encryption is operator-managed and not enforced by the app. Multi-tenant isolation is enforced at the query layer by tenantId. Never leaves the tenant boundary.
AI prompts/responsestriage/diagnosis/chat inputs & outputsSent to the configured LLM provider *only* when a cloud provider is active. The UNTRUSTED_DATA_BOUNDARY prompt defends against prompt injection. Local (Ollama) mode keeps this data in-box.
Audit logsAuditEvent rows (action, actor, target, hash)SHA-256 chained (tamper-evident), export-gated.
Secretssession secret, API keys, SSH keysHeld server-side / in env; never logged. Session secret and API keys are never returned to clients. A tenant's inbound webhook secret is shown to that tenant's admins so they can configure the sender. Encryption of stored third-party credentials at rest is a roadmap item (see trust page).

3. Incident response plan

1.Detection — firing fleet alerts, error-boundary ErrorReport rows, and

the tamper-evident audit chain (a broken hash is an immediate signal).

2.Containment — revoke the affected session/secret, disable the affected

integration toggle (webhooks, SSO, AI mode), isolate the tenant.

3.Remediation — restore from the last known-good state, rotate credentials,

apply the fix behind the normal change/approval flow.

4.Postmortem — a PROBLEM ticket is raised; the Diagnosis → Remediation loop

documents root cause and the fix; the audit chain provides the timeline.

4. Data retention

•Tickets / comments / audit events — retained for the lifetime of the

tenant (demo default; no auto-deletion). Production must set a policy aligned

with the customer's obligations (30-day demo default is documented in the

Admin → Compliance tab).

•Logs — fleet telemetry log lines are synthetic and ephemeral (last tick).
•Embeddings — the vectors.db sidecar mirrors the published KB + synthetic

data; deleting a KB article removes its embedding (deleteBySource).

•Deletion — deleting a tenant cascades to all its rows, embeddings and

uploads (uploads live under the tenant's directory).

5. Change management

•Every code change is committed with a Conventional Commits message and a

passing test suite (tsc, eslint, vitest).

•Deploys are a Docker rebuild of a pinned git revision (single-node

docker compose up -d --build); destructive *data* changes (remediation,

fleet actions) always route through the human approval gate.

•The audit chain records who did what, when, and with what payload.

security/pen-test-plan.md

TriagoAI — Penetration Test Plan

A scoping document for an external penetration test ahead of SOC 2. This is a

plan, not the test itself; findings should be tracked as TriagoAI tickets

and remediated through the normal change/approval flow.

1. Scope

•Web application — all authenticated routes (queues, tickets, chat, admin,

knowledge, infra, evidence pack, reports).

•Public endpoints — /login, /signup, marketing pages, and the

unauthenticated API surfaces (rate-limited login, error-boundary reporting).

•API — inbound webhooks, email intake, SSO/SCIM, AI intake/assist/chat,

audit export, demo/live tick endpoints.

•Docker deployment — the single-node docker compose stack (Next.js on

node:22-alpine, SQLite/libsql + vectors.db sidecar, port mapping

80/3004 → 3000).

Out of scope

•The host OS / Hetzner infrastructure (provider responsibility).
•Third-party services (Stripe, WorkOS, M365, LLM providers) — test only the

TriagoAI integration points, not the vendors.

2. Methodology

•OWASP Top 10 — injection (SQL/NoSQL via libsql, prompt injection via

untrusted intake), broken authentication (JWT session forgery, SSO mock flow),

broken access control (tenant isolation, RBAC on admin-only endpoints,

approval gate bypass), SSRF via the SSO/webhook/LLM outbound calls, XSS in

rendered ticket/comment/agent content.

•API security — authn/z on every /api/* route, idempotency of inbound

intakes (dedupe), rate-limiting, Content-Type/payload validation, and the

authorization: Bearer webhook-secret check.

•AI-specific — prompt-injection resistance of the UNTRUSTED_DATA_BOUNDARY,

tool-call allowlist enforcement (no execute_sql), and the self-critique /

human-approval gates.

3. Tools

•Burp Suite Community / OWASP ZAP — manual + automated web/API scanning.
•ffuf / sqlmap (read-only, non-destructive) — parameter fuzzing and SQLi

probing against the SQLite backend.

•nuclei — targeted template checks for known Next.js misconfigurations.
•Manual review — session/JWT handling, tenant-scoping of every Prisma query,

and the audit chain implementation.

4. Schedule & frequency

•Pre-launch — one full assessment before any production deployment.
•Post-launch — a focused re-test after any auth, tenancy, or agent-action

change; a full re-test annually or on major architecture changes.

•Continuous — dependency scanning and tsc/eslint/vitest on every

commit; the tamper-evident audit chain is exercised by automated tests.

5. Reporting format

Each finding is filed as an TriagoAI ticket and a report entry with:

1.Severity (Critical / High / Medium / Low / Informational) mapped from

CVSS or OWASP Risk Rating.

2.Asset & endpoint affected.
3.Steps to reproduce (proof-of-concept).
4.Impact (confidentiality / integrity / availability, tenant isolation).
5.Recommendation and the resolving commit/PR reference.

Findings are reviewed in the same CAB/approval flow as any change, and the

remediation commit is linked back to the ticket for the audit trail.

FAQ

Common problems, their causes and fixes — troubleshooting reference and postmortems.

docs/problem.md

TriagoAI — Problems & Troubleshooting

Known issues, their causes and fixes, plus a record of past production

incidents and the postmortems that fixed them.

Quick reference

SymptomCause / fix
prisma generate / engine download failsTLS intercepted — NODE_TLS_REJECT_UNAUTHORIZED=0 (set in Dockerfile + local env)
Port 3004 serves OLD codeColima SSH tunnel holds *:3004; a stray next dev binds IPv6. Rebuild the container
Docker build serves stale appCOPY . . cached in Colima VM → docker compose build --no-cache
localhost:3004 not reachablecolima start, then docker compose up -d
docker compose build hangs at npm install → ECONNRESETColima VM resets npm TLS handshake. ENV NODE_TLS_REJECT_UNAUTHORIZED=0 must appear before RUN npm install
Native binaries truncated after npm installRe-copy @next/swc-*, lightningcss, @tailwindcss/oxide, sharp from a known-good node_modules
Login/signup drops the session cookieBrowsers drop Secure cookies over plain HTTP → keep COOKIE_SECURE unset (opt-in)

Postmortems

"error 2090111630" after creating a workspace (2026-08-16)

•Symptom: signup → error 2090111630.
•Cause: 2090111630 was a Next.js error *digest*, not a real code. Phase 5

had set the session cookie secure: NODE_ENV === "production", but the local

Docker run is next start over plain HTTP. Safari drops Secure cookies

over HTTP, so the post-signup cookie never stored → redirect to /queues →

requireUser() threw UNAUTHENTICATED.

•Fix: secure: process.env.COOKIE_SECURE === "true" (opt-in, default off),

documented in .env.example/compose. Also added a self-diagnosing error

boundary (components/error-boundary.tsx + lib/error-diagnostics.ts) and the

ErrorReport table + admin "Errors" tab.

Stale code on port 3004 (2026-08-15)

•Symptom: UI edits invisible at localhost:3004.
•Cause: (1) 3004 is the Docker container (Colima), not next dev;

(2) a Colima SSH mux held IPv4 *:3004 while a stray next dev bound IPv6

:3001; (3) docker compose build used a stale layer cache.

•Fix: docker compose build --no-cache && docker compose up -d.

DeepSeek wraps output in code fences (2026-08-15)

•Symptom: live LLM output came back as json / markdown.
•Fix: stripCodeFences() in lib/utils.ts applied to the Knowledge body

and Remediation narrative; extractJson already handled fences for

triage/diagnosis.

Tenant deletion FK violation (2026-08-21)

•Symptom: db.tenant.deleteMany failed P2003 FOREIGN KEY constraint

after on-call shifts were seeded.

•Cause: OnCallShift.user was a required relation → Prisma default

ON DELETE RESTRICT, blocking the tenant → user cascade.

•Fix: onDelete: Cascade on OnCallShift.user (migration

20260821064108_oncall_shift_user_cascade).

Error reporting

Runtime errors surface through the self-diagnosing boundary and persist to

ErrorReport (admin → Errors). The tamper-evident audit chain doubles as a

detection signal — a broken hash marks the chain.

Escalation

For anything not here, run the repro locally, file it as an TriagoAI ticket, and

append the postmortem above once fixed (per the "update docs after each session"

convention in CONTEXT.md).

TriagoAI

AI-native IT service management. Agents that close the loop - with an inspectable timeline and tamper-evident audit chain.

Product

  • Platform
  • Architecture
  • Pricing
  • Security
  • Trust center
  • What's next
  • For financial services

Resources

  • Documentation
  • API reference
  • Blog
  • Live demo
  • Sign in
  • Create a workspace
  • Changelog
  • Status
  • Book a demo
  • Command Center →

Company

  • About
  • Contact

Legal

  • Privacy policy
  • Terms of service

© 2026 TriagoAI v0.3.0

Controls designed against SOC 2 / ISO 27001 frameworks - certification is a future step, not claimed