# The Mental Model

> The unifying picture: agents are teammates (not tools), multiplexing work like Multics for small human+AI teams. Workspace is the atomic unit of isolation and visibility. Server data lives only in TanStack Query; client UI state lives only in Zustand. WS events never write state — they only invalidate. The same business logic runs on web and desktop because three package layers plus thin platform adapters enforce the boundaries.

- Repository: multica-ai/multica
- GitHub: https://github.com/multica-ai/multica
- Human wiki: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf
- Complete Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/llms-full.txt

## Source Files

- `README.md`
- `packages/core/platform/core-provider.tsx`
- `server/cmd/server/main.go`
- `server/migrations/001_init.up.sql`
- `pnpm-workspace.yaml`
- `turbo.json`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [README.md](README.md)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/core/realtime/use-realtime-sync.ts](packages/core/realtime/use-realtime-sync.ts)
- [server/migrations/001_init.up.sql](server/migrations/001_init.up.sql)
- [pnpm-workspace.yaml](pnpm-workspace.yaml)
- [turbo.json](turbo.json)
- [packages/core/package.json](packages/core/package.json)
- [packages/views/package.json](packages/views/package.json)
- [packages/ui/package.json](packages/ui/package.json)
</details>

# The Mental Model

Multica is not a workflow automation tool with AI bolted on — it is a platform built from the ground up around one conviction: **agents are teammates, not tools**. The name is a deliberate nod to Multics, the 1960s operating system that introduced time-sharing so multiple users could share one machine as if each had it to themselves. Multica applies that same multiplexing idea to software teams: a small group of humans and autonomous agents can move like a much larger team by sharing the same task lifecycle, the same board, and the same runtime infrastructure.

Understanding how Multica works at depth requires holding five mental models in your head simultaneously: the agent-as-teammate model, the workspace-as-isolation-unit model, the server-state-in-TanStack-Query model, the client-state-in-Zustand model, and the WS-as-invalidation-only model. These five ideas are not independent design choices — they are mutually reinforcing constraints that make the system coherent across two completely different host platforms (web and Electron desktop) without duplicating business logic.

---

## Agents Are Teammates, Not Tools

In conventional AI tooling, an agent is a function you call and wait for. In Multica, an agent has a **profile**, an **assignee slot** on the issue board, an **activity timeline**, a **status** (`idle`, `working`, `blocked`, `error`, `offline`), and the ability to create issues, post comments, and report blockers autonomously — exactly the same affordances a human member has.

The schema makes this concrete. The `issue` table stores `assignee_type` as either `'member'` or `'agent'`, with a corresponding `assignee_id` UUID. Creator attribution follows the same pattern: `creator_type` + `creator_id`. There is no "AI output" column or separate "bot actions" table. Agents participate in the same data model as humans.

```sql
-- server/migrations/001_init.up.sql (lines 52–72)
CREATE TABLE issue (
    assignee_type TEXT CHECK (assignee_type IN ('member', 'agent')),
    assignee_id UUID,
    creator_type TEXT NOT NULL CHECK (creator_type IN ('member', 'agent')),
    creator_id UUID NOT NULL,
    ...
);
```

Agents also have their own table with first-class status tracking:

```sql
-- server/migrations/001_init.up.sql (lines 36–49)
CREATE TABLE agent (
    status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('idle', 'working', 'blocked', 'error', 'offline')),
    runtime_mode TEXT NOT NULL CHECK (runtime_mode IN ('local', 'cloud')),
    max_concurrent_tasks INT NOT NULL DEFAULT 1,
    ...
);
```

The Multics analogy is explicit in the README: "For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation."

Sources: [server/migrations/001_init.up.sql:36-72](), [README.md:41-51]()

---

## Workspace Is the Atomic Unit of Isolation

Every entity in the system — agents, issues, members, labels, skills, runtimes — is scoped to a `workspace_id`. The workspace table has a unique `slug` that drives URL routing and the `X-Workspace-Slug` header. There is no global namespace for issues or agents: `workspace_id` is a foreign key on every resource table, and `ON DELETE CASCADE` ensures resources are destroyed when the workspace is destroyed.

```sql
-- server/migrations/001_init.up.sql (lines 15-23)
CREATE TABLE workspace (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    slug TEXT UNIQUE NOT NULL,
    ...
);

-- Member join table: workspace_id is NOT NULL and cascades
CREATE TABLE member (
    workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    UNIQUE(workspace_id, user_id)
);
```

On the frontend, workspace identity is URL-driven. The `[workspaceSlug]` layout resolves the slug and calls `setCurrentWorkspace(slug, wsId)` on mount. The API client reads the slug from that singleton for the `X-Workspace-Slug` header. Every TanStack Query cache key for workspace-scoped data includes the `wsId`, making workspace switching automatic: when the active workspace ID changes, the cache key changes, the right data appears, and no manual invalidation is needed.

Sources: [server/migrations/001_init.up.sql:15-33](), [packages/core/platform/core-provider.tsx:51-54]()

---

## Server State Lives Only in TanStack Query

All data fetched from the API — issues, agents, members, workspace lists, inbox items, runtimes — lives exclusively in the TanStack Query cache. No copy of server data is written into Zustand stores. This is a hard architectural rule, not a preference.

The `CoreProvider` initializes the `QueryProvider` at the outermost layer of the component tree, making the query client available everywhere:

```tsx
// packages/core/platform/core-provider.tsx (lines 86-107)
const tree = (
  <QueryProvider>
    <AuthInitializer ...>
      <WSProvider ...>
        {children}
      </WSProvider>
    </AuthInitializer>
  </QueryProvider>
);
```

The pnpm catalog pins `@tanstack/react-query` at `^5.96.2` and `zustand` at `^5.0.0` as first-class workspace-wide dependencies, making their role in the architecture explicit:

```yaml
# pnpm-workspace.yaml (lines 17-18)
zustand: "^5.0.0"
"@tanstack/react-query": "^5.96.2"
```

Sources: [packages/core/platform/core-provider.tsx:86-107](), [pnpm-workspace.yaml:17-18]()

---

## Client UI State Lives Only in Zustand

Zustand owns all client-side state: UI selections, filters, view modes, modal open/close state, tab layout (desktop), active chat session ID, and navigation history. All shared Zustand stores live in `packages/core/` — not in `packages/views/` or the apps — so both the web app and the Electron desktop app read from the same store instances.

The auth store and chat store are initialized as module-level singletons in `CoreProvider`, created once on first render and never recreated (Vite HMR preserves module-level state across hot reloads):

```tsx
// packages/core/platform/core-provider.tsx (lines 22-63)
let initialized = false;
let authStore: ReturnType<typeof createAuthStore>;
let chatStore: ReturnType<typeof createChatStore>;

function initCore(...) {
  if (initialized) return;
  // ... create api, authStore, chatStore
  initialized = true;
}
```

The critical rule: **never duplicate server data into Zustand**. If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth that will drift.

Sources: [packages/core/platform/core-provider.tsx:22-63]()

---

## WS Events Never Write State — They Only Invalidate

This is the most commonly misunderstood rule in the architecture, and it is enforced with explicit documentation in the source.

When a WebSocket event arrives, `useRealtimeSync` (mounted inside `WSProvider`) dispatches one of two responses:

1. **Prefix-based invalidation (default):** The event type's prefix (e.g., `"agent"`, `"issue"`, `"task"`) maps to a `refreshMap` entry that calls `qc.invalidateQueries(...)`. A 100 ms debounce prevents rapid-fire refetches during bulk operations.
2. **Specific event handlers:** Fine-grained handlers for events like `issue:updated`, `issue:created`, and `workspace:deleted` apply surgical cache patches via `qc.setQueryData(...)` and then follow up with `qc.invalidateQueries(...)` to let the server remain authoritative.

```ts
// packages/core/realtime/use-realtime-sync.ts (lines 176-187)
/**
 * Centralized WS -> store sync. Called once from WSProvider.
 *
 * Uses the "WS as invalidation signal + refetch" pattern:
 * - onAny handler extracts event prefix and calls the matching store refresh
 * - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
 * - Precise handlers only for side effects (toast, navigation, self-check)
 */
```

The comment at line 664 in `use-realtime-sync.ts` is explicit about why Zustand writes were removed from WS handlers:

```ts
// packages/core/realtime/use-realtime-sync.ts (lines 664-669)
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed
// during the invalidate → refetch window and the UI rendered duplicates.
```

On reconnect, the handler calls `invalidateWorkspaceScopedQueries(qc)` to recover any events missed while disconnected — again via invalidation, not state reconstruction.

Sources: [packages/core/realtime/use-realtime-sync.ts:176-374](), [packages/core/realtime/use-realtime-sync.ts:664-669](), [packages/core/realtime/use-realtime-sync.ts:929-941]()

---

## Three Package Layers + Thin Adapters = One Codebase, Two Platforms

The monorepo is organized into three shared packages plus thin platform adapters in each app. This is what allows the same business logic to run on both the Next.js web app and the Electron desktop app without duplication.

```
┌─────────────────────────────────────────────────────────┐
│  apps/web/          apps/desktop/                       │
│  (Next.js)          (Electron + react-router-dom)       │
│  ┌──────────┐       ┌──────────┐                        │
│  │platform/ │       │platform/ │  ← NavigationAdapter,  │
│  │(next/*)  │       │(RR-dom)  │    cookie/token auth   │
│  └────┬─────┘       └────┬─────┘                        │
│       └────────┬──────────┘                             │
│                ▼                                        │
│  ┌─────────────────────────────────────────────────┐    │
│  │  packages/views/   (@multica/views)             │    │
│  │  Business pages + components                    │    │
│  │  zero next/*, zero react-router-dom             │    │
│  │  → imports @multica/core + @multica/ui          │    │
│  └──────────────┬──────────────────────────────────┘    │
│                 │                                       │
│  ┌──────────────┴────────────┐  ┌────────────────────┐  │
│  │  packages/core/           │  │  packages/ui/      │  │
│  │  (@multica/core)          │  │  (@multica/ui)     │  │
│  │  Stores, queries, API     │  │  Atomic components │  │
│  │  client, WS, i18n         │  │  zero business     │  │
│  │  zero react-dom           │  │  logic             │  │
│  └───────────────────────────┘  └────────────────────┘  │
│                                                         │
└─────────────────────────────────────────────────────────┘
```

**`packages/core` (`@multica/core`):** Zero `react-dom`, zero `localStorage` (uses `StorageAdapter`), zero `process.env`. All Zustand stores live here. This package contains the API client, the WebSocket client, the TanStack Query definitions (query keys and option factories), and `CoreProvider` — the root initializer that every app wraps around its tree.

**`packages/ui` (`@multica/ui`):** Zero `@multica/core` imports. Pure atomic UI components backed by Base UI (`@base-ui/react`) primitives, styled with Tailwind semantic tokens.

**`packages/views` (`@multica/views`):** Zero `next/*` imports, zero `react-router-dom` imports. Business pages and feature components that consume `@multica/core` and `@multica/ui`. Routing is done through a `NavigationAdapter` injected by the host app.

Each app's `platform/` directory is the only place where framework-specific APIs live. The web app's platform layer uses `next/navigation`; the desktop app's platform layer uses `react-router-dom`. Everything above that boundary is identical.

The pnpm workspace config establishes this structure:

```yaml
# pnpm-workspace.yaml (lines 1-3)
packages:
  - "apps/*"
  - "packages/*"
```

Turborepo enforces build ordering: `build` depends on `^build`, so `packages/core` always builds before `packages/views`, which always builds before `apps/web` or `apps/desktop`.

Sources: [pnpm-workspace.yaml:1-3](), [turbo.json:18-20](), [packages/views/package.json:54-61](), [packages/ui/package.json:1-10](), [packages/core/platform/core-provider.tsx:65-124]()

---

## How the Five Models Compose

At runtime, a single user action — say, an agent completing an issue — touches all five models at once:

1. **Agent-as-teammate:** The agent posts a comment and changes the issue status, just as a human member would. The `creator_type = 'agent'` field distinguishes the author, but the activity appears in the same timeline.
2. **Workspace isolation:** The server emits a `task:completed` WebSocket event scoped to the workspace. Only clients subscribed to that workspace receive it.
3. **WS invalidation:** `useRealtimeSync` receives `task:completed` and calls `qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) })` and several other workspace-keyed queries. It writes nothing to Zustand.
4. **TanStack Query refetch:** The invalidated queries refetch from the API. The issue detail page, the agent card, and the activity feed all update from the same cache keys — no duplication, no drift.
5. **Zustand client state:** The active filter selection, view mode, and tab layout are untouched by the server event. They remain exactly as the user set them.

Both the web browser and the Electron desktop window run this exact sequence through `packages/core/realtime/use-realtime-sync.ts` without any platform-specific branching — because the WS client, the QueryClient, and all the query keys live in `@multica/core`, shared equally by both apps.

Sources: [packages/core/realtime/use-realtime-sync.ts:292-328](), [packages/core/platform/core-provider.tsx:86-107](), [server/migrations/001_init.up.sql:36-72]()

---

## Summary

Multica's mental model is a set of five mutually reinforcing constraints: agents use the same data model as humans; workspaces are the only isolation boundary; all server data lives in TanStack Query; all client UI state lives in Zustand; WebSocket events never write state directly but always invalidate queries. These constraints are enforced structurally by the three-layer package architecture (`core` → `views` → `apps`) and its thin platform adapters. The result is a single shared codebase that runs identically on web and Electron desktop, where human and AI teammates are genuinely interchangeable participants on the board.

Sources: [packages/core/realtime/use-realtime-sync.ts:176-188](), [packages/core/platform/core-provider.tsx:21-63]()
