Platform

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.

Architecture

A worker pool, a kernel pipeline, one database.

Full request lifecycle and ER diagram:docs/wiki/Architecture.md.

Multi-tenancy

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.

  1. Reject before the handler

    EnforceTenantIsolation resolves the tenant from the JWT and refuses cross-tenant requests before any route code runs.

  2. Predicate in every query

    Handlers and repositories bind an explicit tenant_id from TenantContext. A real-engine suite attempts cross-tenant reads against every tenant-owned table and asserts rejection.

  3. Context that can't outlive its request

    On persistent FrankenPHP workers, TenantContext is resolved, locked for the request, and reset before the worker takes the next one.

Plugins

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.

discoveredloadedactivefaileddisabled

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.

Agent surface

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.

Connect a client →

POST /api/mcp/tokensissue an agent token
# 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 allow
Stack

Boring where it should be.

Backend runtimePHP 8.4 on FrankenPHP persistent workers
DatabasePostgreSQL 15 — one shared instance, logical tenancy, worker-scoped connection pool
Web clientNext.js (App Router), React, TypeScript, Tailwind CSS v4, shadcn/Radix
Design tokensOKLCH, generated to CSS, JSON and Dart from one source; white-label overridable per tenant
Mobile / desktopFlutter tokens package; a Tauri reference desktop app with an offline, bundled FrankenPHP host running unmodified plugins
TestsPHPUnit 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.