How Whity works.
One deployment, one PostgreSQL database, many organisations — each logically isolated. Domain behaviour ships as plugins. Agents reach it through the same API as the web client.
A worker pool, a kernel pipeline, one database.
Full request lifecycle and ER diagram:docs/wiki/Architecture.md.
Isolation, enforced three times.
Every tenant-scoped row carries a tenant_id. Any one layer failing alone should still not be enough to leak data — so the check is made at three independent points in the request.
A reserved system tenant (tenant_id = 0) is the one identity permitted to act across boundaries, for platform operations like provisioning a new tenant.
Reject before the handler
EnforceTenantIsolationresolves the tenant from the JWT and refuses cross-tenant requests before any route code runs.Predicate in every query
Handlers and repositories bind an explicit
tenant_idfromTenantContext. A real-engine suite attempts cross-tenant reads against every tenant-owned table and asserts rejection.Context that can't outlive its request
On persistent FrankenPHP workers,
TenantContextis resolved, locked for the request, and reset before the worker takes the next one.
Hot-loaded, isolated, and built on an MIT boundary.
Plugins are dropped into /plugins/, discovered by reflection, and moved through a lifecycle with no restart. Each runs behind an error boundary.
A plugin depends on whity/plugin-sdk — interfaces, data shapes, HTTP contracts, event constants, and nothing but PHP at runtime. Core's ownRequest and Response are subclasses of the SDK's, so a plugin never imports core. The SDK ships conformance test cases a plugin extends toprove its tenant isolation and its offline-host behaviour. That boundary is what the licence is built on: the SDK is MIT, core is AGPL.
Tools are derived, not declared.
The MCP server (POST /mcp, JSON-RPC 2.0) answers tools/listby reading the OpenAPI specification the router already generates. Every route with a request/response schema becomes a tool: the name is the route'soperationId, the input schema is the route's request schema. Plugin routes are included the moment the plugin loads.
tools/call is enforced the same way a browser request is — the caller's token is checked against the route's required permission. Long-lived MCP tokens are issued and revoked through the management API. Embedding a workflow engine was evaluated and deferred: the API is the automation surface.
# request
{ "name": "ops-agent", "scope": ["tools:call"] }
# 201 Created
{
"jti": "01HXYZ…",
"token": "eyJ…",
"name": "ops-agent",
"scope": ["tools:call"],
"expires_at": "2026-12-10T00:00:00+00:00"
}
# then: POST /mcp with Authorization: Bearer <token>
# tools/list → the derived catalogue, filtered to
# what this token's grants allowBoring where it should be.
| Backend runtime | PHP 8.4 on FrankenPHP persistent workers |
| Database | PostgreSQL 15 — one shared instance, logical tenancy, worker-scoped connection pool |
| Web client | Next.js (App Router), React, TypeScript, Tailwind CSS v4, shadcn/Radix |
| Design tokens | OKLCH, generated to CSS, JSON and Dart from one source; white-label overridable per tenant |
| Mobile / desktop | Flutter tokens package; a Tauri reference desktop app with an offline, bundled FrankenPHP host running unmodified plugins |
| Tests | PHPUnit with real-engine coverage for data-layer logic, PHPStan, Infection mutation testing, Playwright end-to-end |
Go deeper.
The README, architecture wiki, ADRs and CLI reference live with the code.