diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md
index 30e59d79a..4a899f764 100644
--- a/apps/mana/CLAUDE.md
+++ b/apps/mana/CLAUDE.md
@@ -171,14 +171,16 @@ Svelte 5 runes are mandatory — no legacy `let count = 0; $: doubled = count *
The companion is a **second actor** that works alongside the human in every module. Full pipeline live end-to-end:
-- **Actor attribution** — every event, record, and sync row carries `{ kind: 'user' | 'ai' | 'system' }`. Code: `src/lib/data/events/actor.ts`.
-- **AI policy** — per-tool `auto | propose | deny`. Proposable tool names come from `@mana/shared-ai`'s `AI_PROPOSABLE_TOOL_NAMES`; the mana-ai service runs a boot-time drift guard against the same list. Code: `src/lib/data/ai/policy.ts`.
-- **Proposal inbox** — drop `` into any module page to render pending proposals inline with approve / freitext-reject buttons. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/food`.
-- **Missions** — long-lived autonomous work items at `/companion/missions` with concept + objective + linked inputs + cadence. Both the foreground tick AND the server-side `mana-ai` service produce plans; `data/ai/missions/server-iteration-staging.ts` translates server-source iterations into local Proposals on sync.
+- **Actor attribution** — every event, record, and sync row carries `{ kind, principalId, displayName }` (+ mission/iteration/rationale for AI). `principalId` is the userId / agentId / `system:` sentinel; `displayName` is cached at write time so rename doesn't rewrite history. Factories in `@mana/shared-ai/src/actor.ts`; runtime ambient context in `src/lib/data/events/actor.ts`.
+- **Agents** — named AI personas that own Missions. `/ai-agents` module for CRUD (policy editor, memory, budget, concurrency). Default "Mana" agent auto-bootstrapped on first login; legacy missions backfilled. `data/ai/agents/{store,queries,bootstrap}.ts`.
+- **AI policy** — per-tool `auto | propose | deny`. Lives on the agent (`agent.policy`). Proposable tool names come from `@mana/shared-ai`'s `AI_PROPOSABLE_TOOL_NAMES`; the mana-ai service runs a boot-time drift guard against the same list. Resolution in `src/lib/data/ai/policy.ts`; executor loads `agent.policy` for every AI write.
+- **Proposal inbox** — drop `` into any module page to render pending proposals inline with approve / freitext-reject buttons. Cards show the owning agent's name + avatar chip. Wired in `/todo`, `/calendar`, `/places`, `/drink`, `/food`.
+- **Missions** — long-lived autonomous work items at `/ai-missions` with concept + objective + linked inputs + cadence + **owning agent** (AgentPicker in the create flow). Both the foreground tick AND the server-side `mana-ai` service produce plans under the agent's identity; `data/ai/missions/server-iteration-staging.ts` translates server-source iterations into local Proposals on sync.
- **Input picker** — `` sources candidates from the `input-index` registry (notes / kontext / goals / tasks / calendar). The Runner resolves via the parallel `input-resolvers` registry. Encrypted tables (notes, tasks, …) decrypt client-side only.
-- **Workbench timeline** — `/companion/workbench` renders every AI-attributed event grouped by mission iteration. Each bucket has a **Revert button** that undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first).
+- **Scene lens** — workbench scenes can bind to an agent via `scene.viewingAsAgentId` (context menu → "An Agent binden…"). Pure UI lens, not a data-scope change. `SceneAppBar` shows the agent avatar on bound scene tabs.
+- **Workbench timeline** — `/ai-workbench` renders every AI-attributed event grouped by mission iteration with per-**agent** filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket **Revert button** undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate **"Datenzugriff"** tab exposes the server-side decrypt audit (for missions with Key-Grants).
-Full architecture (Planner prompt + parser in `@mana/shared-ai`, server-side runner, Postgres actor column, materialized snapshots, Prometheus metrics + status.mana.how integration): [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §20](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md).
+Full architecture (Planner prompt + parser in `@mana/shared-ai`, server-side runner, Postgres actor column, materialized snapshots, Multi-Agent gating, Prometheus metrics + status.mana.how integration): [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md`](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md) §20 (AI Workbench) + §21 (Mission Grants) + §22 (Multi-Agent Workbench).
## Reference Documents
diff --git a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte
index 6a3ef41ab..5355cf0d8 100644
--- a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte
+++ b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte
@@ -99,8 +99,16 @@
{#each proposals.value as p (p.id)}
-
- KI schlägt vor
+ {#if p.actor?.kind === 'ai'}
+
+ 🤖
+ {p.actor.displayName}
+
+ schlägt vor
+ {:else}
+
+ KI schlägt vor
+ {/if}
{#if showModuleBadge && p.intent.kind === 'toolCall'}
{@const mod = getTool(p.intent.toolName)?.module ?? '?'}
{mod}
@@ -204,6 +212,25 @@
letter-spacing: 0.02em;
text-transform: lowercase;
}
+ .agent-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.0625rem 0.375rem;
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, transparent);
+ color: color-mix(in oklab, var(--color-primary, #6b5bff) 95%, var(--color-fg, #000));
+ font-size: 0.75rem;
+ text-transform: none;
+ letter-spacing: 0;
+ }
+ .agent-avatar-dot {
+ font-size: 0.875rem;
+ line-height: 1;
+ }
+ .agent-name {
+ font-weight: 600;
+ }
.intent {
margin: 0.375rem 0 0;
diff --git a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts
index 99588b63c..86b4e3e19 100644
--- a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts
+++ b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.ts
@@ -57,6 +57,11 @@ export interface TimelineBucket {
rationale: string;
firstTimestamp: string;
events: DomainEvent[];
+ /** Owning Agent — cached off the first AI actor in the bucket. All
+ * events in a bucket share the same (agentId, missionId, iterationId)
+ * tuple so reading from the first is lossless. */
+ agentId: string;
+ agentDisplayName: string;
}
export function bucketByIteration(events: readonly DomainEvent[]): TimelineBucket[] {
@@ -79,6 +84,8 @@ export function bucketByIteration(events: readonly DomainEvent[]): TimelineBucke
rationale: a.rationale,
firstTimestamp: e.meta.timestamp,
events: [e],
+ agentId: a.principalId,
+ agentDisplayName: a.displayName,
});
}
}
diff --git a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte
index b06a83a44..97c608149 100644
--- a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte
@@ -6,6 +6,7 @@
import { ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
+ import { useAgents } from '$lib/data/ai/agents/queries';
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
import { fetchDecryptAudit, type AuditRow } from '$lib/data/ai/audit/queries';
import { isMissionGrantsEnabled } from '$lib/api/config';
@@ -13,6 +14,7 @@
let moduleFilter = $state(null);
let missionFilter = $state(null);
+ let agentFilter = $state(null);
const events = $derived(
useAiTimeline({
@@ -21,9 +23,18 @@
limit: 500,
})
);
- const buckets = $derived(bucketByIteration(events.value));
+ const allBuckets = $derived(bucketByIteration(events.value));
+ // Agent filter is applied client-side after bucketing because the
+ // useAiTimeline query is keyed by module/mission only today. If the
+ // volume ever grows large enough for this to matter, push it into
+ // the query.
+ const buckets = $derived(
+ agentFilter ? allBuckets.filter((b) => b.agentId === agentFilter) : allBuckets
+ );
const missions = $derived(useMissions());
const missionTitleById = $derived(new Map(missions.value.map((m) => [m.id, m.title])));
+ const agents = $derived(useAgents());
+ const agentById = $derived(new Map(agents.value.map((a) => [a.id, a])));
const allModules = $derived(Array.from(new Set(events.value.map((e) => e.meta.appId))).sort());
function describeEvent(e: DomainEvent): string {
@@ -147,6 +158,15 @@
{/each}
+
{#if tab === 'audit'}
@@ -193,6 +213,7 @@
{:else}
{#each buckets as b (b.key)}
+ {@const bucketAgent = agentById.get(b.agentId)}
@@ -201,6 +222,11 @@
+
+ {bucketAgent?.avatar ?? '🤖'}
+
+ {bucketAgent?.name ?? b.agentDisplayName}
+ ·
{missionTitleById.get(b.missionId) ?? b.missionId}
{#if b.rationale}
@@ -373,6 +399,17 @@
.when .time {
font-size: 0.6875rem;
}
+ .agent-avatar {
+ display: inline-block;
+ margin-right: 0.25rem;
+ }
+ .agent-name {
+ font-weight: 600;
+ }
+ .mission-sep {
+ margin: 0 0.25rem;
+ color: hsl(var(--color-muted-foreground));
+ }
.mission-title {
font-weight: 600;
color: hsl(var(--color-primary));
diff --git a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
index d112eb074..c6e2a54cf 100644
--- a/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
+++ b/docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
@@ -1892,3 +1892,66 @@ Vollstaendiger Plan: [`docs/plans/ai-mission-key-grant.md`](../plans/ai-mission-
- Cross-User-Missions (pro Grant genau ein User).
- Automatische Key-Rotation (Master-Key-Rotation invalidiert alle Grants → User re-consented beim naechsten Edit).
- Grant-Sync-Konflikte (werden ueber normales LWW aufgeloest; bei Scope-Mismatch wirft der Resolver `scope-violation` und die Mission pausiert).
+
+## 22. Multi-Agent Workbench (ab 2026-04-15)
+
+Upgrade von Single-User, Single-AI ("Mana") zu Single-User, Multi-Agent. Missionen gehoeren jetzt einem benannten Agent; Scenes koennen als Lens an einen Agent gebunden werden. Full context + decisions: [`docs/plans/multi-agent-workbench.md`](../plans/multi-agent-workbench.md).
+
+### Datenmodell
+
+```
+Agent {
+ id, name (unique per user), avatar, role,
+ systemPrompt, memory, // encrypted at rest
+ policy: AiPolicy, // per-tool + per-module + global default
+ maxConcurrentMissions, maxTokensPerDay,
+ state: active|paused|archived
+}
+
+Mission.agentId?: string // owning agent; legacy records backfill-stamped
+WorkbenchScene.viewingAsAgentId?: string // UI lens, does not affect data scope
+
+Actor {
+ kind: 'user' | 'ai' | 'system',
+ principalId: string, // userId | agentId | 'system:'
+ displayName: string, // cached at write time — rename doesn't rewrite history
+ // AI-only:
+ missionId?, iterationId?, rationale?
+}
+```
+
+### Identity flow
+
+1. User creates an agent in `/ai-agents`. Default "Mana" agent is auto-bootstrapped on first login and inherits the existing user-level policy.
+2. Missions are created under an agent (`AgentPicker` in the create flow). Legacy missions were backfilled to the default agent via localStorage-sentinelled one-shot migration.
+3. `executor.executeTool` loads `getAgent(actor.principalId).policy` for every AI write; `mana-ai` does the same server-side via `agent_snapshots` (LWW projection mirroring `mission_snapshots`).
+4. Every write stamps `Actor.displayName = agent.name` at write time. Workbench timeline + Revert remain correct even after the agent is renamed or deleted (ghost-agent marker on tab).
+5. `mana-ai` tick filters `AI_AVAILABLE_TOOLS` by agent policy before the prompt, injects plaintext `systemPrompt + memory` in an `` delimiter block (ciphertext fields stay server-invisible by design — foreground runner picks up encrypted context).
+6. Scenes optionally bind `viewingAsAgentId`. Pure UI lens: SceneAppBar shows the agent avatar on the scene tab, Workbench timeline defaults its filter to that agent. No data-scope change.
+
+### Gate order in the server tick
+
+Before an LLM call even happens:
+- `agent.state === 'archived'` → skip silently, bump `agentDecisionsTotal{decision='skipped-archived'}`
+- `agent.state === 'paused'` → same with `skipped-paused`
+- `activeRuns[agentId] >= agent.maxConcurrentMissions` → `skipped-concurrency`, defer to next tick
+- Otherwise → `ran`
+
+Missions without an owning agent don't produce this metric; `plansWrittenBackTotal` is the universal "did we run" counter.
+
+### Scene-Agent binding semantics
+
+`scene.viewingAsAgentId` is a **lens**, not a scope. The open apps in the scene still read the same user data regardless of which agent (if any) is bound. The binding drives:
+- Agent avatar on the scene tab (SceneAppBar)
+- Default `agent` filter in the AI Workbench timeline
+- Default selected agent when creating a mission from a bound scene (future — Phase 8 polish)
+
+This is deliberately orthogonal: one agent can appear in many scenes; one scene can be unbound ("neutral workspace"). Binding is set/changed via the scene context menu → "An Agent binden…" dialog.
+
+### Nicht-Ziele
+
+- **Kein Agent-to-Agent Messaging.** Agents laufen unabhaengig.
+- **Kein Meta-Planner ueber Agents.** Agents erzeugen sich keine Missionen selbst; der User bleibt Mission-Creator.
+- **Keine Team-Features.** Andere User / geteilte Daten kommen in einem separaten Plan nach dieser Iteration.
+- **Keine Agent-Memory-Self-Modification.** Memory wird nur vom User editiert.
+- **Keine Per-Agent-Encryption-Domains.** Alle Agents sehen alle Daten des einen Users. Mission-Key-Grants bleiben per-Mission.
diff --git a/services/mana-ai/CLAUDE.md b/services/mana-ai/CLAUDE.md
index 3a76319ca..63f531b84 100644
--- a/services/mana-ai/CLAUDE.md
+++ b/services/mana-ai/CLAUDE.md
@@ -52,12 +52,23 @@ Was steht (Phase 0-2, Backend):
Was offen ist (Phase 3, Frontend):
-- [ ] Webapp `MissionGrantDialog` + Consent-Flow im `/companion/missions`-Editor.
-- [ ] Revoke-Button + "Mission → Datenzugriff" Audit-Tab in `/companion/workbench`.
-- [ ] Scope-Change-UX: neue Records → Re-Consent-Prompt.
-- [ ] `GET /internal/audit?missionId=` Endpoint (read-only) fuer die UI.
-- [ ] Feature-Flag `PUBLIC_AI_MISSION_GRANTS=false` default + Rollout (till → beta → alpha).
-- [ ] Produktions-Keypair generieren + in Mac-Mini Secrets ablegen.
+- [x] Webapp `MissionGrantDialog` + Consent-Flow im Mission-Detail.
+- [x] Revoke-Button + "Datenzugriff" Audit-Tab im Workbench.
+- [x] `GET /api/v1/me/ai-audit` JWT-gated Endpoint live.
+- [x] Feature-Flag `PUBLIC_AI_MISSION_GRANTS` + Cloudflare-Tunnel.
+- [x] Produktions-Keypair auf Mac-Mini unter `secrets/mana-ai/`.
+
+## Status: v0.5 (Multi-Agent Workbench)
+
+Der Runner wird agent-bewusst — Missionen gehoeren einem benannten Agent, Policy und Memory leben auf dem Agent, Concurrency + Budget werden pro Agent respektiert.
+
+- [x] `mana_ai.agent_snapshots` Tabelle (LWW-Projektion von `agents` aus `sync_changes`).
+- [x] `refreshAgentSnapshots` + `loadActiveAgents` parallel zum Mission-Snapshot-Refresh.
+- [x] `ServerMission.agentId` + `ServerAgent.policy` durchgereicht.
+- [x] Tick resolvt pro Mission den Agent, gated `archived`/`paused`/`concurrency`, schreibt iteration unter `makeAgentActor(agent)` Identitaet.
+- [x] `` Prompt-Block mit plaintext `role` + `systemPrompt` + `memory` (ciphertext wird uebersprungen).
+- [x] `filterToolsByAgentPolicy` schneidet `deny`-Tools raus bevor der Planner sie sieht.
+- [x] Metrik `mana_ai_agent_decisions_total{decision}`.
## Port: 3067