agent-workspace.md144.7 KBView on GitHub
# The Agent Workspace — a first-class view for one agent

## 1) Introduction — goal, present state, future state

We want an agent to be a place you can go: click "Cold Outbound" on the home screen and land in a workspace with four tabs — **Output** (the files it produced), **Config** (one page carrying everywhere it gets invoked from, the connections it may reach, and its instructions), **Memory** (what it has learned about how you want it to work), and **Previous Runs** (every chat and every execution) — with a debug chat rail alongside Config, Memory and Previous Runs, and a plain chat you can hold with the agent itself. Today every fact behind three of those tabs already exists in the backend but none of them is addressable *by agent*: a subagent is a Markdown document with YAML frontmatter under `user/playbooks/{aopId}/subagents/` ([subagents.ts:32](apps/server/src/services/playbook/subagents.ts)), the places it is invoked from live as `<ref>` nodes inside the compiled PLAYBOOK.md ([compile-playbook.ts:104](apps/server/src/services/playbook/compile-playbook.ts)), its runs are `agent_executions` rows carrying an indexed `agent_id` ([aop-schema.ts:1581](apps/server/src/db/aop-schema.ts)), its output lands under the per-agent namespace `user/agent-{agentId}/` ([convention-paths.ts:94](apps/server/src/services/documents/convention-paths.ts)), and the chat harness already registers it as a native SDK subagent ([subagents.ts:57](apps/server/src/mastra/workflows/chat/harness/subagents.ts)) — they are simply scattered across four subsystems with no read that assembles them. **Memory is the one genuinely new thing**: an agent has no way to remember that you rewrote its draft, told it to stop cc-ing someone, or corrected the same mistake three weeks running, so every agent starts every run exactly as naive as the day it was written. The change is therefore assembly plus six backend additions — an agent→invocation-source reverse index, per-agent file attribution, an MCP tool policy captured at connection setup and narrowed per agent, chat threads bindable to an agent, **a per-agent memory folder of plain markdown files, with a universal capture-and-reflect preamble**, and publish/duplicate verbs — with the playbook still the single source of truth for triggers and the document still the single source of truth for instructions.

## 2) Present state

### 2.1 Architecture diagram

```text
                        ┌──────────────────────────────────────────┐
                        │  documents  (virtual paths, one table)   │
                        └──────────────────────────────────────────┘
    user/playbooks/{aopId}/PLAYBOOK.md          user/playbooks/{aopId}/subagents/{name}.md
       documentType='playbook'                     documentType='agent'
       metadata.playbook_manifest                  frontmatter: name/description/model/
       metadata.compiled_playbook                              enabled/agent_id/permissions
              │                                                body: the instructions
              │  <trigger type="cron"> … <ref id="{docId}"/>            │
              │  ── the ONLY link from a trigger to an agent ──────────►│
              ▼                                                         │
  runPlaybookSectionExecution                                           │
  [playbook-execution-triggers.ts:360]                                  │
    resolves refs → AgentDescriptor{ id: frontmatter.agent_id }         │
              │                                                         │
              ▼                                                         │
        runAgent [automations.ts:294]  ── instructions = the doc body, verbatim.
              │                            NOTHING is prepended about learning,
              │                            and nothing is read back from past runs.
              ├── INSERT agent_executions (run_id, agent_id, aop_id,
              │      parent_run_id, status, output …)  + agent_tool_calls
              │
              └── tools write docs via writeDocument [documents/index.ts:361]
                        ├─ user/agent-{agentId}/overview      ← per-agent namespace
                        ├─ user/agent-{agentId}/archives/{d}     (only two conventions
                        └─ conversation/{convId}/…                exist under it today)
                                 (no actorLabel / runId recorded — gap G2)

  ── the user corrects the agent, and nothing happens ───────────────────────────

  human edits the doc the agent just wrote
        │
        ▼
  document_updates row  { origin: 'human', actor_user_id, from_seq, to_seq, update }
        │
        └──► read ONLY by unseenAgentEditsForConversation (the unread badge).
             The preceding agent session is not linked to it (actor_agent_id is
             unpopulated), so "the user rewrote what I produced" is invisible. G11.

  ── separately, the chat side ──────────────────────────────────────────────────

  chat_threads (id, user_id, name, page_key, context jsonb)
        │                                      └─ primaryConversation {id,name}
        │                                         ── unindexed jsonb (G7)
        │                                      └─ no agent binding at all (G6)
        ▼
  run-chat-agent-sdk        systemPrompt = the general Cedar chat prompt
        agents    = buildSubagentDefinitions()  ← every AOP subagent, as a Task target
        mcpServers = buildUserMcpServers()      ← ALL the user's MCP servers, unscoped

  ── and the UI ─────────────────────────────────────────────────────────────────

  /brain → CompanyExplorer.tsx  (a subagent doc opens as markdown + AgentDocHeader)
  /home  → AgentHomeHero + AgentHomeBelowChat (pills · next meeting · agenda)
  Settings → SystemsAndCredentialsSection  (name · serverUrl · authHeader · instructions — no tools)
  app/(routes)/agents/page.tsx  ← 1162 lines of mock-data prototype, NOT routed
```

### 2.2 Step-by-step walkthrough

1. **A subagent is read** — `getAllSubagentsForAop` at [subagents.ts:32](apps/server/src/services/playbook/subagents.ts)
   - `LIKE 'user/playbooks/{aopId}/subagents/%.md'` OR `LIKE 'organisation/playbooks/{orgAopId}/subagents/%.md'`; user rows shadow org rows on filename collision; `enabled: false` rows are dropped.
   - Data after this step:
     ```json
     [{ "agentId": "b41c…", "name": "meeting-prep", "description": "Preps every meeting",
        "enabled": true, "scope": "user",
        "documentPath": "user/playbooks/9f2…/subagents/meeting-prep.md",
        "documentId": "0e77…" }]
     ```
   - Summary only. `getSubagentDocsForAop` at [aop-agents.ts:65](apps/server/src/services/aop/aop-agents.ts) additionally returns `instructions`, `model`, `fillInstructions`, `avatar`, `outputFieldIds`.

2. **Frontmatter is parsed** — `parseFrontmatter` at [reference-resolver.ts:449](apps/server/src/services/playbook/reference-resolver.ts)
   - The whole config surface of an agent today:
     ```ts
     { name?, description?, when_to_use?, model?, permissions?: string[],
       output_type?, enabled?, agent_id?, fill_instructions? }
     ```
   - `permissions` is the only capability field and it is **skills only** — it becomes `AgentDescriptor.allowedSkills` at [playbook-execution-triggers.ts:410](apps/server/src/services/playbook/playbook-execution-triggers.ts) and at [run-single-subagent.ts:103](apps/server/src/services/playbook/run-single-subagent.ts). No `tools`, `mcp_servers`, `files` or `memory` key exists.

3. **The playbook is compiled at save time** — `parsePlaybookManifest` at [trigger-parser.ts:141](apps/server/src/services/playbook/trigger-parser.ts) then `compilePlaybook` at [compile-playbook.ts:104](apps/server/src/services/playbook/compile-playbook.ts)
   - Written to `documents.metadata.playbook_manifest` and `documents.metadata.compiled_playbook`.
   - Data after this step (the shape Config needs, keyed the *wrong way round*):
     ```json
     { "version": 1,
       "global": { "cronBlocks": [ { "schedule": "0 7 * * 1-5", "timezone": "America/Los_Angeles",
                                     "nodes": [ { "type": "ref", "id": "0e77…" } ] } ],
                   "eventBlocks": { "meeting": { "nodes": [ { "type": "ref", "id": "0e77…" } ] } },
                   "beforeMeetingBlocks": [ { "minutes": 30, "nodes": [ … ] } ] },
       "stages": { "discovery": { "eventBlocks": { "email": { "nodes": [ … ] } } } },
       "allRefIds": ["0e77…", "3a91…"] }
     ```
   - **This is source → agent.** Nothing indexes agent → source. Gap **G1**.

4. **A trigger fires** — `runPlaybookSectionExecution` at [playbook-execution-triggers.ts:360](apps/server/src/services/playbook/playbook-execution-triggers.ts)
   - Loads each referenced doc, builds a synthetic `AgentDescriptor` from its frontmatter, calls `runAgent` once per ref.
   - Data handed to `runAgent`:
     ```json
     { "id": "b41c…", "aopId": "9f2…", "userId": "usr_…", "name": "meeting-prep",
       "allowedSkills": [], "triggerType": "before_meeting" }
     ```

5. **The run is recorded** — `runAgent` at [automations.ts:294](apps/server/src/services/aop/automations.ts)
   - The agent's `instructions` are the doc body plus any `<additional_context>` the trigger block carried. **Nothing else is prepended, and nothing is read back from prior runs** — the agent has no channel through which past experience could reach it. Gap **G11**.
   - Inserts `agent_executions` with `agent_id`, `parent_run_id`, `aop_id`, `event_type`, `source`, `status`, `output`; each tool call writes `agent_tool_calls`.
   - Data after this step:
     ```json
     { "runId": "run_8f…", "agentId": "b41c…", "parentRunId": "run_3d…",
       "status": "completed", "eventType": "meeting", "output": "Prepped 3 meetings" }
     ```
   - `idx_agent_executions_agent_id` and `idx_agent_executions_parent_run_id` both exist, so both "every run of this agent" and "who spawned it" are already cheap — they have no caller. `getAgentExecutions` at [agent-executions.ts:120](apps/server/src/trpc/routes/agent-executions.ts) filters on `threadId | conversationId | status | search | executionId` and **not** on `agentId`. Gap **G5**.

6. **The agent writes a file** — `writeDocument` at [documents/index.ts:361](apps/server/src/services/documents/index.ts)
   - Sets `documents.lastEditedBy = 'agent'` and calls `writeFileAsYjs` at [writeFileAsYjs.ts:270](apps/server/src/services/document-saving/writeFileAsYjs.ts) **without an `actorLabel`**, so `document_updates.actor_label` is null on every agent write ([documents-schema.ts:331](apps/server/src/db/documents-schema.ts)).
   - Data after this step:
     ```json
     { "id": "d19…", "path": "user/agent-b41c…/overview",
       "lastEditedBy": "agent", "documentType": "document" }
     ```
   - Output can be built by path convention today, but "every file this agent edited" is unanswerable. Gap **G2**.

7. **The user rewrites what the agent produced** — `recordHistoryAsync` at [recorder.ts:54](apps/server/src/services/document-history/recorder.ts) → `capture.ts` at [capture.ts:74](apps/server/src/services/document-history/capture.ts)
   - A human edit closes the agent's session and opens a new one (the actor-changed rule at [capture.ts:95](apps/server/src/services/document-history/capture.ts)), producing two adjacent rows over the same `to_seq` range:
     ```json
     [ { "origin": "agent", "actorLabel": null, "toSeq": 8,  "wordsAfter": 412 },
       { "origin": "human", "actorUserId": "usr_…", "fromSeq": 8, "toSeq": 11, "wordsAfter": 380 } ]
     ```
   - **This is the highest-signal correction event in the product and nothing consumes it.** Only `unseenAgentEditsForConversation` at [unseen-agent-edits.ts:141](apps/server/src/services/documents/unseen-agent-edits.ts) reads these rows, and only in the opposite direction (agent edits the human has not seen). Because the agent session carries no `actor_agent_id`, the pair cannot even be attributed to an agent. Gap **G11**.

8. **The one thing adjacent to memory is Zep, and it is not this** — [services/zep](apps/server/src/services/zep)
   - A user-scoped external graph, env-gated on `ZEP_API_KEY` ([client.ts:27](apps/server/src/services/zep/client.ts)) and self-described as a V0 POC for draft learning. No per-agent partition, no UI, nothing the user can open or edit. **This design does not use it, extend it, or depend on it** — noted only so nobody looks for agent memory there.

9. **An MCP server is connected** — `addMcpConnection` at [integrations.ts:2424](apps/server/src/trpc/routes/integrations.ts), UI at [systems-and-credentials-section.tsx](apps/mail/modules/integrations/systems/systems-and-credentials-section.tsx)
   - The form collects four fields: `name`, `serverUrl`, `authorizationHeader`, `instructions`. Persisted as:
     ```json
     { "serverUrl": "https://mcp.stripe.com", "encryptedHeaders": "…",
       "instructions": "Use for billing questions only." }
     ```
   - **The tools are never enumerated, stored, or scoped.** `McpTool { name, description, inputSchema }` is declared in [types.ts](apps/server/src/services/integrations/mcp/types.ts) and `listMcpTools` is fully implemented at [mcp-client.ts:410](apps/server/src/services/integrations/mcp/mcp-client.ts) — the capability exists at the service layer with no caller at connect time. No `toolPolicy`, no per-tool instruction, no tRPC procedure exposing the list. Gap **G3**.
   - `buildUserMcpServers` at [user-mcp-servers.ts:31](apps/server/src/mastra/workflows/chat/harness/user-mcp-servers.ts) then hands **every** connection to **every** chat, with `permissionMode: 'bypassPermissions'` making `allowedTools` advisory. Gap **G4**.

10. **The user chats** — `runChatViaAgentSdk` at [run-chat-agent-sdk.ts](apps/server/src/mastra/workflows/chat/run-chat-agent-sdk.ts)
    - `systemPrompt` is the general Cedar chat prompt ([chat-agent.ts](apps/server/src/mastra/agents/chat-agent.ts)); `agents` comes from `buildSubagentDefinitions` at [subagents.ts:57](apps/server/src/mastra/workflows/chat/harness/subagents.ts), reachable only if the model chooses `Task`.
    - `chat_threads` has `id, user_id, name, page_key, color, context` ([chat-schema.ts:12](apps/server/src/db/chat-schema.ts)) — **no `agent_id`**, so a chat can never be *with* an agent, and nothing said in a chat can reach the agent's next scheduled run. Gap **G6**.
    - The conversation a chat is about lives at `context.primaryConversation`, written by `chat.setPrimaryConversation` at [chat.ts:496](apps/server/src/trpc/routes/chat.ts) through `applyContextOps` at [merge.ts:70](apps/server/src/mastra/utils/context-items/merge.ts). It works, but it is **unindexed jsonb**, and `repointConversationDocuments` at [conversation-repoint.ts:93](apps/server/src/services/documents/conversation-repoint.ts) does not touch chat threads, so a deal merge orphans every chat pointing at the losing id. Gap **G7**.

11. **The UI renders it** — [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx)
    - The render switch special-cases `attachment`, `html`, `table`, `playbook` (lines 1168–1176). `agent` falls through to the generic markdown editor with `AgentDocHeader` ([AgentDocHeader.tsx](apps/mail/modules/aop/components/AgentDocHeader.tsx)) above it. This is the whole of today's agent UI.
    - `/home` renders `AgentHomeHero` + `AgentHomeBelowChat` ([AgentHomeHero.tsx](apps/mail/modules/home/components/AgentHomeHero.tsx)). **No agents row.**
    - `app/(routes)/agents/page.tsx` is a 1162-line prototype over [mock-data.ts](apps/mail/app/(routes)/agents/mock-data.ts), **not registered** in [routes.ts](apps/mail/app/routes.ts) — only `/agents/playbook` is.
    - `files.createAgent` at [files.ts:489](apps/server/src/trpc/routes/files.ts) **does not create an agent**: it forwards to `createAgent` at [file-system/index.ts:420](apps/server/src/services/file-system/index.ts), whose entire body is `return createFolder(scope, input)`. Its only reference anywhere is a `vi.fn()` in [files-router-auth.test.ts:66](apps/server/src/trpc/routes/__tests__/files-router-auth.test.ts). Gap **G8**.

12. **Sharing, today** — `assertAuthorizedScope` at [files.ts:115](apps/server/src/trpc/routes/files.ts)
    - `user/` paths are readable **only by their owner** (Cedar staff excepted); `organisation/` paths are readable by the whole org. Beyond that, sharing is a public UUID token ([share.ts](apps/server/src/services/documents/share.ts)) — link-only, not team access.
    - The only team-sharing primitive an agent has is being written at org scope, where `getAllSubagentsForAop` already merges it with user-shadows-org. There is no `duplicate` and no `publish` verb (gap **G9**), and no way to grant an agent specific files (gap **G10**).

## 3) Designed state

### 3.1 Architecture diagram

```text
   /home                                        /agents/{agentId}
   ┌───────────────────────────┐                ┌──────────────────────────────────────────────┐
   │ Good afternoon, Jesse.    │                │ 🤖  Cold Outbound   [Personal] [Share] [Debug]│
   │ ┌───────────────────────┐ │                │ Output · Config · Memory · Previous Runs     │
   │ │      composer         │ │                ├───────────────────────────┬──────────────────┤
   │ └───────────────────────┘ │                │  CONFIG is ONE page:      │  debug rail      │
   │  ▸ Daily Agenda    (3)    │  ◄─ COLLAPSED  │   1 Invocation sources    │  (Config,        │
   │  ▾ Agents                 │                │   2 Connections           │   Memory,        │
   │    ▾ Active         (2)   │     click      │   3 Instructions          │   Prev-Runs)     │
   │      🤖 Cold Outbound ────┼───────────────►└───────────────────────────┴──────────────────┘
   │      🤖 Coaching          │
   │    ▸ In-conversation (5)  │   one level of folders, from `folder:` frontmatter
   │    ▸ Background      (3)  │   agents are ROWS, not cards — the list is the point
   │                     [+]   │
   └───────────────────────────┘                                │
     ┌──────────────┬─────────────────────────────┬─────────────┴──────┬────────────────────┐
     ▼              ▼                             ▼                    ▼                    ▼
 agent.getOutputs  agent.getInvocationSources  agent.getConnections  agent.getMemory   agent.getRuns
     │                     │                        │                    │                  │
     │      ONE resolver — what is CONFIGURED to fire this agent            │              │
     │            ┌──────────────────┐              │                    │                  │
     │            │ playbook trigger │◄── compiled_playbook ref reverse walk                 │
     │            │  cron · event · any · before-meeting · field-change · webhook            │
     │            │ agent            │◄── <ref> in a SIBLING subagent's body (declared)      │
     │            │                  │    + agent_executions.parent_run_id  (observed)       │
     │            └──────────────────┘              │                    │                  │
     │        NOT here: chat and Run-now (not configuration — a chat is in                   │
     │        Previous Runs, Run-now is a header button); post-api endpoints                 │
     │        (the agent CALLS those — they belong in Connections)                           │
     ▼                                              ▼                    ▼                  ▼
 documents                            connection.metadata      user/agent-{id}/    agent_executions
 path LIKE user/agent-{id}/%             .toolPolicy (NEW)          memory/         WHERE agent_id=?
   AND NOT LIKE …/memory/%               ── THE CEILING ──       corrections.md          ∪
        ∪                                        ∩               preferences.md     chat_threads
 document_updates.actor_agent_id        frontmatter.mcp_servers      notes.md       WHERE agent_id=?
        (NEW — the "touched" list)       ── THE NARROWING ──    plain .md documents

  ── the memory loop: a folder of .md files the agent reads and appends to ──────

   user/agent-{id}/memory/  ← seeded empty: corrections.md · preferences.md · notes.md
                              ordinary documents. No new table, no new doc type.

   (a) the user rewrites what the agent wrote
        document_updates: [agent session, toSeq 8] → [human session, fromSeq 8]
              │  adjacent pair, same doc, agent-then-human  ── detectable ONLY once
              ▼                                                actor_agent_id exists (Ph5)
        captureCorrection()  ──append a bullet──►  memory/corrections.md
   (b) the user says something durable in a bound chat
        AGENT_MEMORY_PREAMBLE tells the agent to append
              │  (and NOT to append one-off task parameters)
              ▼
        write-document(mode:'append')  ──────────►  memory/preferences.md
   (c) weekly, or on demand
        reflect()  ── reads recent runs + its own files
              ├─► REWRITES the files condensed, under the char cap
              └─► may PROPOSE an instruction patch  ── never auto-applies
                        │
                        ▼
                  user accepts → patch lands on the agent doc
                  user declines → appended to preferences.md ("declined X")
                                  so it cannot re-propose forever
   (d) every subsequent run
        runAgent instructions = AGENT_MEMORY_PREAMBLE
                              + the memory folder, concatenated + capped  ◄── the point
                              + the agent doc body
                              + <additional_context>
  ── two bindings on a chat thread, deliberately modelled differently ───────────

     ACTOR   chat_threads.agent_id       column · indexed · immutable after msg 1
             └─► picks the system prompt, the grant set, run attribution, and
                 WHICH agent's memory the conversation writes into

     SUBJECT context.primaryConversation jsonb · mutable · retargetable mid-thread
             └─► + expression index (NEW) so "chats about this deal" is a real query
             └─► + repointed on conversation merge (NEW) so a merge cannot orphan it
```

### 3.2 Step-by-step walkthrough

**The gap ledger this design closes.** Everything not listed here already works and is only being *read differently*.

| # | Gap | Where it bites | Fix | Phase |
|---|---|---|---|---|
| G1 | No agent → invocation-source index (playbook triggers *and* sibling agents, unified) | Config page §1 | One resolver over `compiled_playbook` + sibling refs + `parent_run_id`; two kinds, config only | 1 |
| G2 | Agent file writes are unattributed | Output tab | `document_updates.actor_agent_id` / `actor_run_id` from the ambient run scope | 5 |
| G3 | MCP tools never enumerated or scoped at setup | Integrations + Config §2 | `metadata.toolPolicy` seeded by `listMcpTools` at connect time | 6 |
| G4 | MCP connections user-global; no per-agent narrowing | Config page §2 | `mcp_servers:` grant, intersected with the ceiling at the call site | 7 |
| G5 | Executions cannot be filtered by agent | Previous Runs | `agentId` on `GetAgentExecutionsInputSchema` | 1 |
| G6 | A chat cannot be bound to an agent | Chat · Memory · Previous Runs | `chat_threads.agent_id` column + harness branch | 8 |
| G7 | The conversation binding is unindexed jsonb and merge-fragile | Chat | Expression index + repoint on conversation merge | 8 |
| **G11** | **An agent cannot learn anything; corrections are recorded and discarded** | **Memory tab** | **Per-agent memory store + universal capture/reflect preamble** | **9–10** |
| G8 | `files.createAgent` creates a folder | "+ New agent" | Delete it; add a real `agent.create` over `authorSubagentDoc` | 11 |
| G9 | No duplicate / publish-to-team verb | Share menu | Two mutations over the existing path scoping | 13 |
| G10 | No per-agent file grants | Config page §2 | `files:` grant, enforced at the tool call site | 13 |

1. **Invocation sources — one question, one resolver** (G1) — `resolveAgentInvocationSources` in `apps/server/src/services/agent-workspace/agent-invocations.ts`
   - **What this section is for.** It answers "when does this agent run, and how do I change that?" — so it lists what is **configured** to fire the agent, and nothing else. Every row is either editable in place (Phase 4) or links to where it is editable. It is not a run log; that is Previous Runs.
   - That test excludes three things it would be tempting to put here:
     - **A chat is not an invocation source.** You opened a chat and talked to the agent. There is nothing to configure and nothing to turn off, and Previous Runs → Chats already lists every one. A row saying "Direct chat" adds no information and dilutes the list.
     - **"Run now" is not an invocation source.** It is available for every agent, always, so as a row it is a constant that tells you nothing. It is a **button in the page header**, next to the enabled toggle.
     - **Post-API endpoints are not triggers.** `postApiBlocks` describes endpoints the agent **calls outward** — `renderPostApiEndpoints` at [playbook-renderers.ts:129](apps/server/src/services/playbook/playbook-renderers.ts) renders them as "To call an endpoint below…". They belong in Config §2 Connections, and are listed there.
   - Splitting what remains into a "triggers" list and an "invoked by" list would put one question in two boxes and leave the user to union them mentally. One resolver, one `AgentInvocationSource[]`, one sorted list. Two kinds:
     - **`playbook`** — the reverse walk. Given the AOP's `compiled_playbook` and one `documentId`, visit `global` and every `stages[*]` section, and within each every block array (`anyBlock`, `eventBlocks`, `cronBlocks`, `beforeMeetingBlocks`, `fieldChangeBlocks`, `webhookBlocks`, `postApiBlocks`), collecting blocks whose `nodes` contain `{ type:'ref', id: documentId }`. Pure over [compiled-playbook-types.ts](apps/server/src/services/playbook/compiled-playbook-types.ts) — no DB in the walk, so it cannot drift from the dispatcher: it reads the same blob.
       A **`webhook`** trigger is one of these variants and is the richest row on the page: it carries a live inbound URL (`POST /webhooks/playbook/:token`), an enabled flag, and a last-fired stamp from `playbook_webhooks` ([playbook-webhook.ts:39](apps/server/src/services/playbook/playbook-webhook.ts)) — the row is where you copy the URL, rotate the token, or turn the endpoint off. It is genuinely inbound, unlike post-api.
     - **`agent`** — another agent invokes this one. **One kind, two independent facts**, because they are the same relationship observed two ways:
       - `declared` — a sibling subagent doc's body contains `<ref id="{documentId}"/>` or `[[doc:{documentId}]]`; a bounded `LIKE` under the `…/subagents/%` prefix.
       - `runs30d` — how often it actually happened, from `parent_run_id` on `agent_executions`, each parent resolved to its own `agent_id` and name.
   - Carrying both on one row is what makes the two failure states legible, and is the whole reason declared and observed are not separate lists:

     | `declared` | `runs30d` | What it means |
     |---|---|---|
     | true | 0 | **Configured, never fired.** The ref is there; something upstream is not reaching it. |
     | false | 310 | **Fires but is not declared.** A parent chose it at runtime via `Task`/`spawn-subagent`. Real, and invisible in the playbook. |
     | true | 310 | Working as configured. |

   - Every row carries the same three fields — `kind`, `label`, `runs30d` — so one row type renders them all.
   - Data after this step:
     ```json
     [ { "kind": "playbook", "trigger": { "type": "cron", "scope": "global",
           "schedule": "0 7 * * 1-5", "timezone": "America/Los_Angeles" },
         "label": "Every weekday at 7:00am", "runs30d": 22, "enabled": true },
       { "kind": "playbook", "trigger": { "type": "webhook", "scope": "global",
           "webhookId": "wh_1c…", "url": "https://api.cedar…/webhooks/playbook/8f3a…",
           "lastFiredAt": "2026-08-25T09:12:00Z" },
         "label": "Inbound webhook · lead-form", "runs30d": 87, "enabled": true },
       { "kind": "agent", "from": { "agentId": "3a91…", "name": "daily-agenda" },
         "declared": true,  "label": "Invoked by Daily Agenda", "runs30d": 0, "enabled": true },
       { "kind": "agent", "from": { "agentId": "77bd…", "name": "review-conversation" },
         "declared": false, "label": "Spawned by Review Conversation", "runs30d": 310, "enabled": true } ]
     ```

2. **Opening an agent** — new route `/agents/:agentId` in [routes.ts](apps/mail/app/routes.ts) → `apps/mail/app/(routes)/agents/[agentId]/page.tsx` → `AgentView.tsx`
   - `agent.get({ agentId })` returns the header block from the same frontmatter `AgentDocHeader` already edits, so header writes keep routing through `aop.updateSubagentHeader` at [aop.ts:435](apps/server/src/trpc/routes/aop.ts) → `applySubagentFrontmatterPatch` at [subagent-frontmatter.ts:54](apps/server/src/services/aop/subagent-frontmatter.ts).
   - **The `agent` document type routes here too**: an `agent` branch in the render switch in [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx), beside the `table` branch (line 1172), mounts `AgentView`.

3. **Config is one page, three sections** — `AgentConfigPage.tsx`
   - Triggers, connections and instructions are the same act: deciding what this agent does. Splitting them into three tabs makes you tab-hop to answer one question ("it fires on every email — can it even reach Slack? and does its body mention Slack?"), and makes the debug rail argue with a tab bar about which tab it belongs to. One scrolling page with three anchored sections, a sticky section nav, and the debug rail pinned beside it.
     - **§1 Invocation sources** — the `AgentInvocationSource[]` list from step 1, editable in Phase 4.
     - **§2 Connections** — the four defaults (Meetings, Gmail, Slack, LinkedIn) plus custom MCP servers rendered as `ceiling ∩ grant`; the `files:` grants land here in Phase 13.
     - **§3 Instructions** — the markdown editor over the doc body, frontmatter hidden (`HideFrontmatterExtension`), with a resolved-grants strip so the author sees the agent's actual reach beside the words telling it what to do.
   - Deep links stay stable: `/agents/:id/config#connections` scrolls to the section rather than switching a tab, so a link in a denial message can point at the exact control that fixes it.

4. **Output tab** — `agent.getOutputs({ agentId })` in `apps/server/src/services/agent-workspace/outputs.ts`
   - Two unions:
     - **Owned**: `documents.path LIKE 'user/agent-{agentId}/%' AND path NOT LIKE 'user/agent-{agentId}/memory/%'` — the namespace `agentNamespacePath` already mints at [convention-paths.ts:94](apps/server/src/services/documents/convention-paths.ts), minus the agent's own head.
     - **Touched**: `document_updates.actor_agent_id = {agentId}` joined to `documents`, `deleted_at IS NULL`, newest session first.
   - Deliberately **excluded**: any `conversation/…` path. A meeting-prep doc belongs to the deal, not the agent — exactly the split the sketch calls for. Those surface as a collapsed "In deals" count with a link out.
   - Data after this step:
     ```json
     { "owned": [ { "documentId": "d19…", "name": "overview", "documentType": "document",
                    "path": "user/agent-b41c…/overview", "updatedAt": "…" },
                  { "documentId": "d20…", "name": "Q3 target list", "documentType": "table",
                    "path": "user/agent-b41c…/q3-target-list", "updatedAt": "…" } ],
       "touched": [ { "documentId": "d31…", "name": "ICP notes", "documentType": "document",
                      "path": "user/kb/icp-notes", "lastTouchedAt": "…" } ],
       "inConversations": { "count": 12 } }
     ```

5. **Attribution is written** (G2) — `writeDocument` at [documents/index.ts:361](apps/server/src/services/documents/index.ts) gains optional `actorAgentId` / `actorRunId`
   - Sourced from the ambient run scope already opened by `instrumentToolMap` ([tool-call-timing.ts](apps/server/src/services/agent-action-queue/tool-call-timing.ts)) — the same `AsyncLocalStorage` that gives `logToolCall` its `runId`. **No tool signature changes.**
   - Forwarded to `writeFileAsYjs` ([writeFileAsYjs.ts:270](apps/server/src/services/document-saving/writeFileAsYjs.ts)), `writeTableAsYjs` ([writeTableAsYjs.ts:365](apps/server/src/services/document-saving/writeTableAsYjs.ts)) and `applyUpdate` ([applyUpdate.ts:411](apps/server/src/services/document-saving/applyUpdate.ts)).
   - Data after this step:
     ```json
     { "documentId": "d31…", "fromSeq": 8, "toSeq": 11, "origin": "agent",
       "actorLabel": "meeting-prep", "actorAgentId": "b41c…", "actorRunId": "run_8f…" }
     ```
   - This column is doing double duty: it powers the Output tab's "touched" list **and** it is the join that makes correction capture (step 8) possible at all. No backfill is possible; the tab says "since \<deploy date\>".

6. **MCP tools are captured at connection setup** (G3) — `addMcpConnection` at [integrations.ts:2424](apps/server/src/trpc/routes/integrations.ts)
   - Tool permissions belong to the connection, at the moment you connect it — not to a settings page you never revisit. `listMcpTools` at [mcp-client.ts:410](apps/server/src/services/integrations/mcp/mcp-client.ts) already does the `tools/list` round trip and simply has no caller here.
   - On connect: probe, persist every discovered tool into `metadata.toolPolicy.rules` with `allowed: false`, render the checklist plus a per-tool instruction field inline before the connection saves.
   - **Deny-by-default for new connections, `allow_all` for existing ones**, so the deploy is a behaviour no-op; a "Review permissions" banner marks the backfilled rows so the migration is visible rather than silent.
   - `lastReviewedAt` stamps every policy edit; a tool from a later `tools/list` absent from `rules` renders **New — not enabled**. A server that grows a `delete_everything` tool must not inherit last quarter's consent.
   - Data after this step:
     ```json
     { "serverUrl": "https://mcp.stripe.com", "encryptedHeaders": "…",
       "toolPolicy": { "mode": "allowlist", "lastReviewedAt": "2026-08-25T…",
         "rules": [ { "toolName": "create_payment_link", "allowed": true,
                      "instruction": "Annual plans only. Never for trials.",
                      "argumentRules": [ { "field": "currency", "oneOf": ["usd"] } ] },
                    { "toolName": "create_refund", "allowed": false } ] } }
     ```
   - Enforcement is `evaluateMcpToolCall(policy, toolName, args)` in `apps/server/src/services/integrations/mcp/tool-policy.ts`, called from [callMcpTool.ts](apps/server/src/mastra/tools/integrations/callMcpTool.ts) **before** `callMcpClient`, recorded via `logToolCall`. A missing required field fails; `pinnedArguments` apply after validation; an uncompilable `matches` fails **closed**.

7. **Per-agent grants narrow the ceiling** (G4) — `resolveAgentGrants` in `apps/server/src/services/agent-workspace/grants.ts`
   - The connection's `toolPolicy` is the ceiling; the agent's `mcp_servers:` frontmatter grant narrows it. **Intersected, never unioned** — a grant naming a tool the connection denies still denies. Filtering what the model *sees* is an optimization; filtering what the dispatcher *executes* is the control.
   - An **absent** `mcp_servers` key means today's behaviour (inherit everything); an **empty list** means nothing. Without that distinction every un-migrated agent silently loses capability on deploy.
   - A connection is owned by Settings → Integrations and shared across agents; the agent page owns *grants over it*. That is the only arrangement in which "reuse tools across agents" is true by construction — add Stripe once, tick two tools for the billing agent and none for the outbound agent.
   - Data after this step:
     ```json
     { "defaults": [ { "key": "gmail", "status": "connected", "granted": true },
                     { "key": "linkedin", "status": "disconnected", "granted": true } ],
       "mcp": [ { "connectionId": "conn_9…", "name": "stripe", "policyMode": "allowlist",
                  "tools": [ { "name": "create_payment_link", "allowedByConnection": true,
                               "granted": true, "instruction": "Annual plans only.", "isNew": false },
                             { "name": "create_refund", "allowedByConnection": false,
                               "granted": false, "instruction": null, "isNew": false } ] } ] }
     ```

8. **Memory — a folder of markdown files** (G11, part 1) — `apps/server/src/services/agent-memory/`

   An agent's memory is **a folder of ordinary markdown documents inside its own namespace**: `user/agent-{agentId}/memory/`, seeded empty with three files.

   | File | What goes in it |
   |---|---|
   | `corrections.md` | What the agent got wrong and how the user fixed it |
   | `preferences.md` | Standing instructions the user has stated |
   | `notes.md` | Anything else worth keeping |

   - **No new table, no new document type, no new write path, no external service.** They are `document` rows like any other, addressed by path, and the agent writes to them with the `write-document` tool it already has in `append` mode ([document-types.ts](apps/server/src/services/documents/document-types.ts) — `DOCUMENT_WRITE_MODE.APPEND` already exists). What is new is the path convention, the grant, and the preamble that tells the agent the folder is there.
   - **Why a folder rather than one file.** A week of corrections would bury a standing preference in one file, and the reflection pass needs to rewrite corrections without touching preferences. Three files is the smallest split that survives that. The agent may add more `.md` files in the same folder — nothing about the set is enforced.
   - **Why inside `user/agent-{agentId}/` rather than a new scope.** The path already carries the ACL, the per-agent partition and the deletion cascade — and it gives the right answer for a shared agent for free: an org-published agent doc is one document, but `user/` is per-user, so **each teammate's copy learns separately**. Sam's cold-outbound agent remembering that Sam hates exclamation marks must not change Jesse's.
   - The Output tab excludes `memory/` and the file tree hides it — the head is not an output, which is the whole distinction being drawn.
   - Data after this step (`corrections.md` after two captures):
     ```md
     # Corrections — what I got wrong, and how it was fixed
     - Dropped the "quick question —" opener; Jesse rewrites it to a direct ask. (2026-08-24)
     - Cut the closing "Let me know if that works!" — replaced with a specific next step. (2026-08-25)
     ```

9. **Memory — capture, injection, reflection** (G11, part 2) — `AGENT_MEMORY_PREAMBLE` in `apps/server/src/mastra/agents/agent-memory-preamble.ts`

   One short constant, composed at **two** call sites so no agent can be missed: the instruction assembly in `runAgent` at [automations.ts:294](apps/server/src/services/aop/automations.ts), and `AGENT_CHAT_SHELL` for bound chats. Every agent gets it whether or not its author knew it existed — that is what "universal" has to mean, and it is why this is not a paragraph each agent's body is expected to carry.

   Three ways something gets written down, in descending order of signal:

   - **(a) The user rewrote what the agent produced.** Free, and by far the strongest signal — a correction the user took the trouble to make by hand. Once step 5 lands `actor_agent_id`, an adjacent `[origin:'agent', actor_agent_id: X] → [origin:'human']` pair on one document *is* the event. `captureCorrection` reconstructs both versions through the existing history machinery, diffs them, asks a small model for the one-sentence lesson, and appends one bullet to `corrections.md`. Runs out of band, capped at one per document per day so an afternoon of editing yields one bullet rather than forty.
   - **(b) The user said something durable in a bound chat.** The preamble instructs the agent to append to `preferences.md` when the user states a standing instruction or corrects it — and, explicitly, **not** for one-off task parameters. "Always cc Sam on renewals" is a memory; "cc Sam on this one" is not. Without that line the files fill with task noise inside a week.
   - **(c) Reflection.** Weekly, and on demand from the Memory tab, the agent reads its recent runs and its own files and **rewrites them condensed** — merging duplicates, dropping what has been contradicted, keeping under the cap. Tidying is a rewrite the agent performs, not an algorithm we maintain.

   **Instruction proposals never auto-apply.** Reflection may propose a patch to the agent's own body; it appears in the Memory tab as an accept/decline card. Auto-applying would let an agent rewrite its own instructions with no audit trail — the exact drift the playbook-as-source-of-truth rule exists to prevent. A **declined** proposal is appended to `preferences.md` ("the user declined X"), or the next reflection proposes it again forever.

   - Data handed to the model on the next run:
     ```json
     { "instructions": "<AGENT_MEMORY_PREAMBLE>\n\n<memory>\n# Corrections …\n# Preferences …\n</memory>\n\n<the doc body>\n\n<additional_context>…</additional_context>" }
     ```
   - The Memory tab renders the files as editable markdown panes with a char count against the cap, plus any open proposals as cards. Editing and deleting are ordinary document operations: the user owns what the agent believes about them.
10. **The two chat bindings** (G6, G7) — the schema call the sketch leaves open

    A chat thread has two relationships that look similar and behave nothing alike. Modelling them the same way is the mistake to avoid.

    | | **Actor** — who you are talking to | **Subject** — what it is about |
    |---|---|---|
    | Value | one agent | one primary conversation (+ N context items) |
    | Lifecycle | set at creation, **immutable after the first message** | retargetable at any turn |
    | Read frequency | every turn, before any prompt is built | on demand |
    | What it decides | system prompt, tool grants, run attribution, **whose memory this writes into** | which deal's data is in scope |
    | Storage | **`chat_threads.agent_id`** — real column, indexed | **`context.primaryConversation`** — jsonb, as today |

    - **Actor → a column.** It gates the prompt and the grant set on every turn, so it must be readable without parsing jsonb; it partitions Previous Runs and the memory writes, so it must be indexable; and it must be **immutable after the first message**, because a transcript produced under two different agents with two different grant sets — and landing in two different memory stores — is unauditable. Retargeting is a *new thread*, which is also the honest UX.
    - No FK on `agent_id`: an agent is a document plus a frontmatter id, not a row. A dangling id degrades to the general prompt with a banner rather than 500-ing.
    - **Subject → stays jsonb.** Already correct: mutable, retargetable, part of the same context set as `items[]`, one writer (`applyContextOps` at [merge.ts:70](apps/server/src/mastra/utils/context-items/merge.ts)). Promoting it to a column adds a second write target and therefore drift, with no upside — it is not on the hot path.
    - Its two real defects get fixed in place:
      - an **expression index** `((context->'primaryConversation'->>'id'))` so "chats about this deal" stops being a scan;
      - **repointing on merge.** `repointConversationDocuments` at [conversation-repoint.ts:93](apps/server/src/services/documents/conversation-repoint.ts) moves documents from the losing conversation to the winner but never touches chat threads, so every chat pointing at the losing id silently orphans — the same class of failure that lost documents on the owner-change merge. Repoint in the same transaction, and extend `assertNoConversationDocumentsLeftBehind` at [conversation-repoint.ts:274](apps/server/src/services/documents/conversation-repoint.ts) to assert none is left.
    - **Why not one polymorphic `chat_bindings` table?** Opposite lifecycles, opposite read paths, opposite cardinalities (0..1 agent; 0..1 primary + N items). One table needs a discriminator, per-kind mutation rules, and a join on the hottest read in the product — three costs for one cosmetic saving.

11. **Chatting with the agent** — bind the thread, swap the prompt
    - When `chat_threads.agent_id` is set, `runChatViaAgentSdk` composes `systemPrompt = AGENT_CHAT_SHELL + AGENT_MEMORY_PREAMBLE + <the memory folder> + <the agent's instruction body>`, narrows `mcpServers` to the resolved grant, and sets `options.agents = { general-purpose }` only — so the agent can fan out but never delegates to *itself*, which is what today's `buildSubagentDefinitions` would otherwise make it do.
    - `AGENT_CHAT_SHELL` stays thin and fixed: the UI/tool contract plus one line establishing that the agent is in a live conversation with its owner rather than running headless. Everything domain-specific comes from the agent's own body and its own memory.
    - **Why not delegate from the general chat agent**: your raw words never reach the agent, two prompts fight over tone, the run is attributed to the chat agent rather than the named one, Previous Runs cannot distinguish a chat *with* the agent from one that mentioned it — and, decisively, **nothing said in the chat would reach the right memory store**. Delegation stays available; it is just not how you talk to an agent.
    - Executions from a bound thread are stamped `agent_executions.agent_id = <bound agent>`, closing the loop to Previous Runs and to the `chat` invocation source with no extra plumbing.
    - The **debug rail** on Config, Memory and Previous Runs is the same bound thread with `DEBUG_MODE_SECTION` appended: it is told it is being inspected, that it should explain its own invocation sources, grants, memory and recent failures, and that it may propose playbook and frontmatter patches for the user to accept. Same thread machinery, different suffix — no second chat runtime.

12. **Creating an agent** (G8) — `agent.create`
    - `files.createAgent` and the `createAgent` alias at [file-system/index.ts:420](apps/server/src/services/file-system/index.ts) are **deleted, not renamed**. The name promises an agent and delivers a folder; there is no caller to preserve. Renaming would keep a function whose only body is `return createFolder(...)`.
    - `agent.create({ name, description?, model? })` calls `authorSubagentDoc` at [author-subagent.ts:87](apps/server/src/services/playbook/author-subagent.ts), which mints an `agent_id`, writes **blank-line-separated** frontmatter (the round-trip requirement in [agent-document-type.md](apps/server/docs/wiki/agent-document-type.md)) and stamps `documentType: 'agent'` via `isSubagentDocPath` at [convention-paths.ts:255](apps/server/src/services/documents/convention-paths.ts).
    - Data after this step:
      ```json
      { "agentId": "e0a2…", "documentId": "c118…",
        "path": "user/playbooks/9f2…/subagents/cold-outbound.md", "documentType": "agent" }
      ```
    - The new agent has no invocation sources and no memory yet — which Config and Memory state plainly, rather than showing empty lists that read like bugs.

13. **Sharing, duplication and team access** (G9, G10) — Phase 13, the last phase
    - **The path is the ACL.** `user/` is owner-only, `organisation/` is org-wide, and `getAllSubagentsForAop` at [subagents.ts:32](apps/server/src/services/playbook/subagents.ts) already merges both with user-shadows-org. No ACL table is introduced.
    - **Publish to team** copies the user subagent doc to `organisation/playbooks/{orgAopId}/subagents/{name}.md`. The author keeps a private override for free; teammates pick the org copy up on their next read.
    - **Duplicate** copies the doc to a new filename and **mints a new `agent_id`** — load-bearing, because `agent_id` keys the output namespace, the memory namespace, and `agent_executions`. A duplicate reusing the id would silently merge two agents' files, heads and run history.
    - **Memory does not travel by default.** A duplicate starts with an empty folder — the original's memory was learned against a different context, and inheriting it is how a new agent arrives pre-loaded with someone else's wrong conclusions. Publishing copies no memory files unless the author explicitly opts in, with a preview of the exact text being shared: `corrections.md` records one person's corrections and may quote deal specifics they never meant to publish. Teammates then learn their own, per-user, at `user/agent-{agentId}/memory/`.
    - **Pointing an agent at specific files** is a `files:` frontmatter grant of paths or doc ids with `: rw` / `: r` suffixes — the same grammar as `boards:` in [wiki/board-documents.md](apps/server/docs/wiki/board-documents.md) — resolved at dispatch and enforced inside the document tools' call sites, with a denial that names the file, the grant held, and where to widen it.
    - **The one hazard**: publishing an agent whose `files:` grant names `user/`-scoped paths produces an org agent that fails for everyone but its author. `publishToOrg` validates every grant is org-scoped or public and refuses with the offending paths listed.

### 3.2.1 Amendments found during implementation

Recorded as they were discovered by running the real path, not by re-reading the plan.
Each names what the doc assumed, what is actually true, and what changed.

**A1 — Per-trigger run counts are not computable. `runs30d` on a `playbook` source is always `null`.**
§3.2 step 1 assumed a run could be attributed to the trigger block that fired it, via
`agent_executions.event_type`. It cannot. Measured over jesse's real 30-day history:
meeting-prep (before-meeting trigger), daily-agenda (cron) and next-steps (event) carry
`event_type = NULL` on **every** execution, and `source` names only the dispatch path
(`automation`, `pub-sub`, `background-sync`) — the same handful of values regardless of which
block fired. The first headless run of `cedar-cli agent sources` therefore printed
"configured, never fired" against an agent that had run 61 times. A confidently wrong
diagnostic is worse than an absent one, so:
  - `AgentInvocationSource.runs30d` is `null` for `playbook` and `unknown` kinds, and MUST
    render as `—`, never `0`;
  - it stays a real number for `agent` kinds, where `parent_run_id` genuinely attributes a run;
  - the diagnostic moves up one level to `AgentSummary.runCount30d` ("this agent has sources
    configured and has not run in 30 days"), which IS computable and is the signal that was
    actually wanted.
Making it real would mean stamping the firing block's identity onto the execution row in
`runPlaybookSectionExecution` ([playbook-execution-triggers.ts:360](apps/server/src/services/playbook/playbook-execution-triggers.ts)) — a dispatcher change, out of scope here.

**A2 — `getAllSubagentsForAop` hid every disabled agent.** The helper drops
`enabled: false` docs, so the workspace could never list a disabled agent and
`AgentSummary.enabled` could only ever be `true` — leaving no screen on which to turn one back
on, while the Config page rendered a disabled banner that could never fire. Added an opt-in
`includeDisabled` (default `false`, so the two runtime callers — the chat harness's
`options.agents` and the connected-systems rules block — are unchanged, since they are asking
"what may fire"). The management read passes `true`, and `enabled` is now derived rather than
hardcoded.

**A3 — `getAllSubagentsForAop` had no `deleted_at IS NULL` filter** (pre-existing, and the same
bug class as `injectPlaybookDirectory`). This is load-bearing, not tidiness:
`documents_unique_path` is a PARTIAL index on that predicate, so a soft-deleted doc and a live
doc may share one path, and the filename dedup keeps whichever the scan returns first —
deleting and recreating a subagent could shadow the live doc with its own tombstone, and a
deleted agent stayed a delegation target in the chat harness. Filter added.

**A4 — `AgentRun.durationMs` is derived, not stored.** `agent_executions` has no duration
column; it is `completedAt − createdAt`, and `null` while a run is open — "not measured",
never zero.

**A5 — Frontend conventions differ from the frontend-design skill.** The real tRPC hook import
is `@/providers/query-provider` (255 call sites) and not `@/modules/trpc/context` (1). The
newest modules (outbound, home, roadmap) use **zero** Paraglide, so this surface follows them
with plain strings rather than introducing message keys nothing around it uses.

**A6 — `apps/mail/jest.config.cjs` is an explicit allowlist.** `roots` and `testMatch` name
each directory, so a new `modules/agents/__tests__/` is silently NOT RUN until both are
extended. A green `pnpm --filter @zero/mail test` proves nothing about a suite that was never
collected.

**A7 — the dev frontend is not on 5173 here.** React Router picks the first free port from
3000; with sibling worktrees running it lands on **3004**. Resolve the port from the dev log
rather than assuming.

**A8 — the home hero's day is one CARD, and the agent list is a searchable GRID.** Eight
revisions after seeing phases 12 + 14 on screen, superseding the shape those phases describe:

1. **One container: "Daily agenda", tasks beside meetings.** The stack (next meeting →
   agenda → agents) only ever fitted ONE meeting on screen and pushed the tasks under the
   fold. `HomeAgenda` now owns a single bordered card holding both columns —
   `AgendaDocument` left, `AgendaMeetings` right with every one of the day's events — under
   a big **"Daily agenda"** title that belongs to the LAYOUT, not to the editor.
   `AgentHomeNextMeeting` is deleted: the column it duplicated already shows the same next
   meeting with the red now-line. The meetings column takes `openTarget="conversation"` —
   on the home a meeting is a deal you want to look at, so the card opens the conversation
   rather than the meeting-prep doc (the chat overlay stays prep-first, which is what you
   opened the overlay FOR).
2. **The card CLAMPS; it does not hide.** Collapsed is a `max-h` + a fade, so the top of
   both columns stays on screen and Expand lifts it to the full day. That is the difference
   between a card and a closed section with a count on it. The choice still persists per
   user through `useSectionCollapsed`, and the document stays MOUNTED either way — its task
   query and Y.Doc sync must be warm when the card expands. `CollapsibleSectionHeader` and
   `use-agenda-item-count` had no other caller once the header went, and were deleted.
3. **The in-document date heading is dead on the daily surface.** `DateHeadingNode` hid
   itself when the node carried a `view` attr — but documents written before the
   Current|Future cutover carry `date` and no `view`, so they kept drawing a stale
   *"Monday, August 24"* over today's tasks. The hide is now keyed on a **`surface`
   registration option** (`DateHeadingNode.configure({ surface: 'daily' })`), not on the
   node and not on `editor.storage`: the node view has to know on its FIRST render, and
   storage is only assigned in the editor's `onReady`, which lands after it. Conversation
   agendas keep the dated title — there the calendar day is what tells the documents apart.
4. **Meetings render compact next to the tasks, and they are not cards.** `AgendaEventBlock`
   gained a `compact` variant: title + time, the conversation badge pushed to the TOP RIGHT,
   no attendee rings, and **no container at all** — no border, no fill, no shadow. The
   calendar's colour survives as a bare left rule, which is the only chrome the row keeps;
   a list of bordered tiles beside the day's tasks reads as a second set of tasks rather
   than as the shape of the day. "Happening now" moves with it: the full card rings itself
   red, the compact row turns its colour rule red, because a ring needs a box to sit on.
   The no-show button survives in compact form — it only appears while a meeting is
   actually running, so it costs no height the rest of the time.
5. **The daily agenda shows only the USER's events.** The calendar grid is deliberately
   plural — teammates' calendars, room calendars, shared team calendars, all compared at
   once. The daily agenda is the opposite surface: it sits beside the user's task list and
   answers "what am I doing today", so `AgendaMeetings` now filters through
   `makeOwnEventFilter` ([is-own-event.ts](apps/mail/modules/calendar/utils/is-own-event.ts)).

   The rule is PARTICIPATION, NOT ACCESS: an event is yours when your address is on it (a
   guest, the organizer, or the creator). Two traps sit in the way, and both of them shipped
   before being caught:

   - **`attendees[].self` is relative to the calendar the event was READ FROM**, not to the
     signed-in user. On a teammate's subscribed calendar the attendee flagged `self` is the
     TEAMMATE, so a `self`-only filter keeps precisely the events it was added to remove.
   - **"A calendar you own" is not "your calendar".** The first fix treated
     `accessRole: 'owner'` as proof of ownership, which quietly re-admitted everything on any
     SECONDARY calendar the user owns — a team calendar, a second account, an events calendar
     — because Google marks those events `self: true` too, against the calendar's own address.
     Only the calendar flagged **`primary`** counts, and `self` is trusted only there (where
     it is the one thing that rescues an invite sent to an alias we do not know about). A
     solo block — no guest list at all — is kept only on the primary calendar.

   The literal string `'primary'` counts as a primary id as well: `useAllCalendarEvents`
   stamps each event with the id it FETCHED, and that is the alias `'primary'` whenever the
   user has selected no calendars. Without that, the user's own solo blocks vanish from their
   own agenda.

   The filter **fails OPEN, narrowly**: only while BOTH the address and the calendar list are
   still unknown — the first paint. A day that briefly shows too much and then settles is a
   flicker; a day that shows nothing reads as "you have no meetings today", which is a lie the
   user acts on. Once either lands, the rule applies.
6. **The meeting row's detail lives in the event popover, on hover.** The compact row
   carries a title, a time and a conversation badge and nothing else, so everything it
   dropped — attendee rings, who accepted, the RSVP state — comes back in
   `EventDetailsPopover`, opened by HOVER and anchored to the right of the row.

   Hover, not click, because the row's click already belongs to the conversation. That
   forced two changes inside the popover: it takes a `PopoverAnchor` rather than a
   `PopoverTrigger` when `openOnHover` is set (a trigger claims the press), and the
   PORTALED panel carries its own `onMouseEnter`/`onMouseLeave` — nesting it inside the
   row's hover area is impossible, so reaching for a button in it would otherwise close
   it on the way. Open and close delays are deliberately asymmetric (320ms / 160ms):
   long enough not to strobe while the pointer crosses a list, short enough to cross the
   8px gap to the panel.

   A meeting with no conversation stays PRESSABLE and answers with a toast. Doing nothing
   reads as a broken card rather than as a meeting nothing is linked to.
7. **The event popover gained a bottom action row** — the things you do ABOUT a meeting
   rather than to its fields, pinned under the scroll region so they are reachable from
   any scroll position. `Schedule Follow-Up` moved down into it from the top control row,
   which is otherwise the panel's own edit/delete controls. The first slot carries two
   meanings: **Reschedule** normally, and **No show** while the meeting is actually
   running — mid-meeting, "reschedule" is the wrong question and "they haven't shown up"
   is the live one. Both fire the existing prompt builders in `meetingEmailPrompts`.

   Both buttons wear the COMPOSER'S SEND PILL (`bg-action`, `rounded-full`, h-7 — see
   [send-button.tsx](apps/mail/modules/drafting/components/send-button.tsx)) and both sit
   on the right. Each one drafts and sends a mail, so they are the same kind of act as
   Send and should not have to be learned twice.
8. **Agents are cards under a per-folder toggle, and the folders are renamed.** Phase 12
   argued for rows over a card STRIP; the objection was the horizontal scroll, not the card,
   and a wrapping grid has neither problem. Each folder is a toggle carrying its name and
   count, with its cards under it — but **search overrides the fold**: a search that only
   looks inside the folder you happened to have open reports "no matches" for an agent that
   is right there, so a non-empty query opens every folder with a hit and hides the rest.
   The team chip is gone — scope is not what you scan a card for.

   Every agent draws the SAME face — two dots and a smirk on a filled circle, from
   [agent-avatar.tsx](apps/mail/components/icons/agent-avatar.tsx) — and differs by BODY
   COLOUR and by one accessory. The colour is what the eye resolves first, so it is what
   identifies an agent at 16px; twenty distinct line drawings did not, because at that
   size they all collapsed into the same grey scribble. `AgentAvatar` is the single entry
   point, so the AOP editor, the home card and the workspace header land on the same look
   for the same agent. The default is HASHED FROM THE AGENT ID, never from list position:
   position moves when an agent is created, renamed or re-filed, and a face that moves is
   worse than no face. Saved as `metadata.avatar`, in the form `colour/accessory`.

   The folders are now **core · background · in-conversation** — `active` reads as a status
   next to the enabled dot, which is a different question. `resolveAgentFolder` maps the
   legacy `active` string onto `core` so no document has to be rewritten for a label change.

### 3.3 Schema

Full schema:

```ts
// ─────────────────────────────────────────────────────────────────────────────
// 1. chat_threads — the ACTOR binding as a column  (apps/server/src/db/chat-schema.ts)
//    Shown complete; only the marked line and index are new.
// ─────────────────────────────────────────────────────────────────────────────
export const chatThreads = pgTable('chat_threads', {
  id:        text('id').primaryKey(),
  userId:    text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  name:      text('name').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
  deletedAt: timestamp('deleted_at'),
  pageKey=[redacted]page_key'),
  color:     text('color'),

  // The SUBJECT binding lives in here, unchanged: context.primaryConversation {id,name}
  // plus context.items[]. Mutable, retargetable, one writer (applyContextOps).
  context:   jsonb('context').$type<ChatContext>(),

  // NEW — the ACTOR binding. The subagent this thread is a conversation WITH.
  // Null = the general Cedar chat agent (every thread today).
  // Immutable once the thread has a message: a transcript produced under two
  // different agents — two grant sets AND two memory stores — is unauditable.
  // Deliberately NOT an FK: a subagent is a document + a frontmatter id, not a
  // row. A dangling id degrades to the general prompt rather than 500-ing.
  agentId:   text('agent_id'),
}, (t) => [
  index('idx_chat_threads_user_id').on(t.userId),
  index('idx_chat_threads_updated_at').on(t.updatedAt),
  index('idx_chat_threads_page_key').on(t.userId, t.pageKey),
  index('idx_chat_threads_agent_id').on(t.userId, t.agentId),                    // NEW
]);

// NEW — raw SQL migration; drizzle cannot express a jsonb-path expression index.
// Makes the SUBJECT binding queryable without denormalising it into a second
// write target.
//   CREATE INDEX idx_chat_threads_primary_conversation
//     ON chat_threads ((context->'primaryConversation'->>'id'))
//     WHERE deleted_at IS NULL;

// ─────────────────────────────────────────────────────────────────────────────
// 2. document_updates — two new columns  (apps/server/src/db/documents-schema.ts)
//    actor_agent_id does double duty: the Output tab's "touched" list AND the
//    join that makes correction capture possible at all.
// ─────────────────────────────────────────────────────────────────────────────
export const documentUpdates = pgTable('document_updates', {
  id:           uuid('id').primaryKey().defaultRandom(),
  documentId:   uuid('document_id').notNull().references(() => documents.id, { onDelete: 'cascade' }),
  fromSeq:      integer('from_seq').notNull(),
  toSeq:        integer('to_seq').notNull(),
  update:       customType<{ data: Uint8Array; default: false }>({ dataType: () => 'bytea' })('update').notNull(),
  origin:       text('origin').notNull(),              // 'human' | 'agent' | 'system'
  actorUserId:  text('actor_user_id'),                 // set when origin = 'human'
  actorLabel:   text('actor_label'),                   // display name; now populated for agents

  actorAgentId: text('actor_agent_id'),                // NEW — the stable subagent agent_id
  actorRunId:   text('actor_run_id'),                  // NEW — agent_executions.run_id, no FK
                                                       //       (runs are prunable; docs are not)
  editCount:    integer('edit_count').notNull().default(1),
  wordsBefore:  integer('words_before').notNull().default(0),
  wordsAfter:   integer('words_after').notNull().default(0),
  startedAt:    timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
  endedAt:      timestamp('ended_at',   { withTimezone: true }).notNull().defaultNow(),
  isClosed:     boolean('is_closed').notNull().default(false),
}, (t) => [
  uniqueIndex('document_updates_doc_to_seq_idx').on(t.documentId, t.toSeq),
  index('document_updates_doc_from_seq_idx').on(t.documentId, t.fromSeq),
  uniqueIndex('document_updates_open_session_idx').on(t.documentId).where(sql`${t.isClosed} = false`),
  index('document_updates_actor_agent_idx').on(t.actorAgentId, t.endedAt),       // NEW
]);

// ─────────────────────────────────────────────────────────────────────────────
// 3. Agent memory — A FOLDER OF MARKDOWN FILES. No new table, no new document
//    type, no new write path.
//    apps/server/src/services/agent-memory/memory-paths.ts
// ─────────────────────────────────────────────────────────────────────────────
export const AGENT_MEMORY_DIRNAME = 'memory';

/** `user/agent-{agentId}/memory` — excluded from the Output tab's owned query. */
export function agentMemoryDirPath(agentId: string): string {
  return `${agentNamespacePath({ type: 'user' }, agentId)}/${AGENT_MEMORY_DIRNAME}`;
}

/**
 * The files seeded empty on first use. Three, because three is the smallest set
 * that keeps a durable preference from being buried under a week of corrections.
 * The agent may add more `.md` files in the same folder; nothing here is a schema.
 */
export const AGENT_MEMORY_FILES = [
  { name: 'corrections', heading: '# Corrections — what I got wrong, and how it was fixed' },
  { name: 'preferences', heading: '# Preferences — standing instructions from the user'    },
  { name: 'notes',       heading: '# Notes — anything else worth keeping'                  },
] as const;

/** Every one is a plain DOCUMENT_TYPE.DOCUMENT at `${agentMemoryDirPath(id)}/${name}`. */
export function agentMemoryFilePath(agentId: string, name: string): string {
  return `${agentMemoryDirPath(agentId)}/${name}`;
}

/**
 * The whole folder is injected into every run, so it is capped. When the cap is
 * hit the reflection pass REWRITES the files shorter — the agent tidies its own
 * head; there is no compaction algorithm to maintain.
 */
export const AGENT_MEMORY_MAX_CHARS = 6000;
export const AGENT_MEMORY_CAPTURE_PER_DOC_PER_DAY = 1;

// ─────────────────────────────────────────────────────────────────────────────
// 4. Subagent frontmatter — the agent's whole config surface
//    (apps/server/src/services/playbook/reference-resolver.ts)
// ─────────────────────────────────────────────────────────────────────────────
export interface SubagentFrontmatter {
  name?:              string;
  description?:       string;
  when_to_use?:       string;
  model?:             string;      // 'sonnet' | 'opus' | 'haiku' | any custom id
  permissions?:       string[];    // skills — existing; becomes AgentDescriptor.allowedSkills
  output_type?:       string;
  enabled?:           boolean;
  agent_id?:          string;      // keys output paths, MEMORY paths, and agent_executions
  fill_instructions?: string;

  mcp_servers?:       string[];    // NEW — 'name' (whole server) | 'name: toolA, toolB'
  files?:             string[];    // NEW — 'path/or/doc-id: rw' | ': r'; deny-by-default
  boards?:            string[];    // same grammar; see wiki/board-documents.md → Grants
  chat_enabled?:      boolean;     // NEW — may a chat thread bind to this agent (default true)
  memory_enabled?:    boolean;     // NEW — default true. False for agents that must be
                                   //       deterministic run-to-run (e.g. crm-updater).
  folder?:            AgentFolder; // NEW — ONE level, no nesting. See Phase 14.
}

/**
 * The agent tree is exactly one level deep, and the level is a frontmatter STRING —
 * not a path segment and not a `folder` document row.
 *
 * Nesting the path (`…/subagents/background/daily-agenda.md`) was the obvious move and
 * it is wrong: `isSubagentDocPath` is `/subagents\/[^/]+$/`
 * ([convention-paths.ts:255](apps/server/src/services/documents/convention-paths.ts)), so a
 * nested doc silently stops being stamped `documentType:'agent'`, and the user-shadows-org
 * merge keys on `path.split('/').pop()` ([subagents.ts:32](apps/server/src/services/playbook/subagents.ts)),
 * so the same agent filed under two folders would stop shadowing itself.
 * A string in frontmatter changes no path, needs no migration, and makes "one level deep"
 * true by construction rather than by convention.
 *
 * An unrecognised or absent value falls back to 'active' at read time — a folder is an
 * organisational hint, and an agent must never disappear from the list because of one.
 */
export const AGENT_FOLDERS = ['active', 'in-conversation', 'background'] as const;
export type AgentFolder = (typeof AGENT_FOLDERS)[number];

// ─────────────────────────────────────────────────────────────────────────────
// 5. McpConnectionMetadata — tool policy captured at connection setup. Complete.
//    (apps/server/src/services/integrations/mcp/types.ts)
// ─────────────────────────────────────────────────────────────────────────────
export type McpConnectionMetadata = {
  serverUrl:              string;
  encryptedHeaders?:      string;
  instructions?:          string;      // existing — connection-level standing rules
  encryptedRefreshToken?: string;
  accessTokenExpiresAt?:  string;
  dynamicClientId?:       string;
  needsReauth?:           boolean;
  toolPolicy?:            McpToolPolicy;   // NEW
};

export interface McpToolPolicy {
  // Existing connections backfill to 'allow_all' so the deploy is a no-op;
  // connections created after Phase 6 start 'allowlist' with everything denied.
  mode:            'allow_all' | 'allowlist';
  rules:           McpToolRule[];
  lastReviewedAt?: string;   // ISO — drives the "New — not enabled" badge
  discoveredAt?:   string;   // ISO — when tools/list last populated `rules`
}

export interface McpToolRule {
  toolName:         string;
  allowed:          boolean;
  description?:     string;                    // from tools/list, for the checklist UI
  instruction?:     string;                    // NEW — per-tool guidance woven into the prompt
  requireApproval?: boolean;
  pinnedArguments?: Record<string, unknown>;   // applied AFTER validation
  argumentRules?:   McpArgumentRule[];
}

export interface McpArgumentRule {
  field:     string;
  required?: boolean;
  matches?:  string;    // regex; an uncompilable pattern fails CLOSED
  oneOf?:    string[];
}

// ─────────────────────────────────────────────────────────────────────────────
// 6. Read models returned by the new agent router (wire types, no storage)
//    apps/server/src/services/agent-workspace/types.ts — dependency-free so
//    apps/mail can `import type` it without pulling drizzle into the bundle.
// ─────────────────────────────────────────────────────────────────────────────
export type AgentScope = 'user' | 'org';

export interface AgentSummary {
  agentId:         string;
  name:            string;
  description:     string | null;
  enabled:         boolean;
  scope:           AgentScope;
  documentId:      string;
  documentPath:    string;
  avatar:          string | null;
  model:           string | null;
  isSystemDefault: boolean;
  folder:          AgentFolder;     // resolved, never null — absent/unknown ⇒ 'active'
  lastRunAt:       string | null;   // ISO
  runCount7d:      number;
  memoryChars:     number;          // total size of user/agent-{id}/memory/*.md
}

/** The playbook trigger shape carried by a `playbook` invocation source. */
export type PlaybookTriggerRef =
  | { type: 'cron';           scope: 'global' | 'stage'; stage?: string; schedule: string; timezone: string }
  | { type: 'event';          scope: 'global' | 'stage'; stage?: string; eventType: string }
  | { type: 'any';            scope: 'global' | 'stage'; stage?: string }
  | { type: 'before_meeting'; scope: 'global' | 'stage'; stage?: string; minutes: number }
  | { type: 'field_change';   scope: 'global' | 'stage'; stage?: string; field: string; toValue: string | null }
  /**
   * Genuinely inbound: `POST /webhooks/playbook/:token` fires the agent. The row
   * joins the compiled block to its `playbook_webhooks` row so the UI can show and
   * rotate the URL. NOTE there is deliberately no `post_api` variant — postApiBlocks
   * are endpoints the agent CALLS OUTWARD (see renderPostApiEndpoints in
   * playbook-renderers.ts); they are listed under Connections, not here.
   */
  | { type: 'webhook'; scope: 'global' | 'stage'; stage?: string; webhookId: string;
      url: string; lastFiredAt: string | null };

/**
 * THE unified answer to "what is CONFIGURED to fire this agent" (G1).
 *
 * Two kinds, not six. A chat and a Run-now are things that HAPPENED, not things
 * that are configured — a chat lives in Previous Runs and Run-now is a header
 * button, so neither is a row here. Post-api endpoints are calls the agent makes
 * outward and live under Connections.
 *
 * `agent` carries `declared` and `runs30d` on ONE row rather than splitting into
 * declared/observed kinds: the interesting states are the disagreements between
 * them (declared && !runs30d = configured but never fires; !declared && runs30d =
 * fires via runtime Task delegation, invisible in the playbook), and a single row
 * is the only shape that shows a disagreement.
 */
export type AgentInvocationSource =
  | { kind: 'playbook'; trigger: PlaybookTriggerRef;
      label: string; runs30d: number; enabled: boolean }
  | { kind: 'agent';    from: { agentId: string; name: string }; declared: boolean;
      label: string; runs30d: number; enabled: boolean }
  | { kind: 'unknown';  raw: unknown;
      label: string; runs30d: number; enabled: boolean };

export interface AgentConnection {
  key=[redacted];                 // 'gmail' | 'slack' | 'linkedin' | 'meetings' | connectionId
  kind:        'default' | 'mcp';
  name:        string;
  status:      'connected' | 'disconnected' | 'error';
  granted:     boolean;
  policyMode?: 'allow_all' | 'allowlist';
  tools?:      AgentConnectionTool[];  // mcp only
}

export interface AgentConnectionTool {
  name:                string;
  description:         string | null;
  allowedByConnection: boolean;   // the ceiling  (connection.toolPolicy)
  granted:             boolean;   // ceiling ∩ agent grant — what actually runs
  instruction:         string | null;
  isNew:               boolean;   // appeared since toolPolicy.lastReviewedAt
}

export interface AgentOutputFile {
  documentId:   string;
  name:         string;
  path:         string;
  documentType: string;
  updatedAt:    string;
  relation:     'owned' | 'touched';
}

export interface AgentMemoryView {
  /** One entry per .md file in user/agent-{agentId}/memory/. */
  files: Array<{ documentId: string; name: string; content: string; updatedAt: string }>;
  totalChars: number;              // against AGENT_MEMORY_MAX_CHARS
  /** Instruction patches reflection has proposed. Never auto-applied. */
  proposals: Array<{ id: string; rationale: string; oldString: string; newString: string;
                     status: 'open' | 'accepted' | 'declined'; createdAt: string }>;
}

export interface AgentRun {
  runId:      string;
  startedAt:  string;
  status:     'pending' | 'executing' | 'completed' | 'canceled' | 'final' | 'failed';
  trigger:    string | null;
  summary:    string | null;
  durationMs: number | null;
  toolCalls:  number;
}

export interface AgentChatSummary {
  threadId:     string;
  name:         string;
  updatedAt:    string;
  messageCount: number;
  primaryConversation: { id: string; name?: string } | null;   // the SUBJECT binding
}
```

Relationship diagram:

```text
  ┌────────────────────────────────┐
  │ documents                      │
  │  id (uuid) PK                  │
  │  org_id, user_id               │
  │  path            ◄── the ACL   │   user/… = owner-only
  │  document_type   'agent'|'playbook'|'table'|'document'|…
  │  content         (frontmatter + body)          organisation/… = whole org
  │  metadata jsonb                │
  └───┬─────────────┬──────────────┘
      │             │
      │             │ ▼ contains (metadata jsonb, on the PLAYBOOK.md row)
      │             │   compiled_playbook: { global{…}, stages{…}, allRefIds[] }
      │             │        └─ CompiledNode { type:'ref', id } ──ref──► documents.id
      │             │             (the ONLY trigger→agent edge; the invocation-source
      │             │              resolver walks it BACKWARDS for Config §1)
      │             │
      │             │ ▼ contains (content frontmatter, on a subagents/*.md row)
      │             │   SubagentFrontmatter { agent_id, model, permissions[],
      │             │                         mcp_servers[], files[], boards[],
      │             │                         chat_enabled, memory_enabled }
      │             │        agent_id ──logical id──┐
      │             │        mcp_servers[] ──N:M──► connection.name   (the NARROWING)
      │             │        files[]       ──N:M──► documents.path/id (the file grant)
      │ 1:N         │                               │
      ▼             │                               │
  ┌──────────────────────────────┐                  │
  │ document_updates             │                  │
  │  id PK                       │                  │
  │  document_id ──FK──► documents.id  ON DELETE CASCADE
  │  from_seq, to_seq, update    │                  │
  │  origin 'human'|'agent'|'system'                │
  │  actor_user_id ──FK──► user.id                  │
  │  actor_label   (display name)│                  │
  │  actor_agent_id  (NEW) ──logical──────────────► │  the same agent_id string
  │  actor_run_id    (NEW) ──logical──┐             │  (no FK: an agent is a doc,
  └───────┬──────────────────────┘    │             │   not a row)
          │                           │             │
          │  an ADJACENT PAIR on one document —      │
          │  [origin:'agent', actor_agent_id:X, toSeq:N]
          │  followed by [origin:'human', fromSeq:N] │
          │  IS the correction event. Nothing else   │
          │  in the schema records "the user rewrote │
          │  what the agent produced".               │
          ▼                                          │
   captureCorrection() ──writes a row into──────────►│
                                                     │
  ┌──────────────────────────────────────────────────┼──────────────────────────┐
  │  AGENT MEMORY — a folder of ordinary markdown documents. No new table,      │
  │  no new document type, no new write path.        │                          │
  │                                                  │                          │
  │   documents(path = user/agent-{agent_id}/memory/corrections)  type=document │
  │   documents(path = user/agent-{agent_id}/memory/preferences)  type=document │
  │   documents(path = user/agent-{agent_id}/memory/notes)        type=document │
  │   … plus any further .md the agent or the user adds in the same folder      │
  │                                                  │                          │
  │   Written with the write-document tool in APPEND mode — the tool the agent  │
  │   already has. Read back in full (capped) and injected into every run.      │
  │                                                  │                          │
  │   NOTE: `user/…` is PER-USER, so an ORG-published agent doc is one document │
  │   with N memory folders — each teammate's copy learns separately. That      │
  │   falls out of the path convention; it is not extra machinery.              │
  └──────────────────────────────────────────────────┼──────────────────────────┘
                                                     │
  ┌──────────────────────────────────────────────────┼──────────────────────────┐
  │ agent_executions                                 │                          │
  │  run_id (text) PK  ◄──────────────────┐          │                          │
  │  user_id         ──FK──► user.id      │          │                          │
  │  aop_id          ──FK──► agent_operating_procedures.id                      │
  │  conversation_id ──FK──► crm_conversations.id    │                          │
  │  task_id         ──FK──► user_tasks.id           │                          │
  │  agent_id        ──logical──────────────────────►┘  (indexed — the join     │
  │  parent_run_id   ──self-ref, NO FK──┐               Previous Runs, the      │
  │  source 'manual'|'replay'|…         │               the `agent` source's    │
  │  status, event_type, output         │               and memory all use)     │
  └────────────────┬────────────────────┴──────────────────────────────────────┘
                   │ 1:N
                   ▼
      ┌─────────────────────────────┐
      │ agent_tool_calls            │
      │  run_id ──FK──► agent_executions.run_id  ON DELETE CASCADE
      │  tool_name, arguments, result, started_at, created_at
      └─────────────────────────────┘

  ── the two chat bindings ──────────────────────────────────────────────────────

  ┌──────────────────────────────────────┐
  │ chat_threads                         │
  │  id (text) PK                        │
  │  user_id ──FK──► user.id             │
  │  name, page_key, color               │
  │                                      │
  │  agent_id (NEW) ──logical───────────────► documents(subagent).frontmatter.agent_id
  │      ACTOR · column · indexed ·           N:1 · immutable after first message ·
  │      read every turn · ALSO decides       no FK (dangling ⇒ general prompt)
  │      which memory store this writes  │
  │                                      │
  │  context jsonb                       │
  │    ▼ contains                        │
  │      primaryConversation {id,name} ─────► crm_conversations.id
  │      SUBJECT · jsonb · expression-indexed (NEW) · 0..1 · mutable mid-thread ·
  │               repointed on conversation merge (NEW)
  │      items[] {kind,id,label} ───────────► conversation | email_thread |
  │      N per thread                          slack_thread | linkedin_chat |
  │                                            whatsapp_chat | file | task
  └───┬──────────────────────────────────┘
      │ 1:N
      ▼
  ┌────────────────────────────┐
  │ chat_messages              │
  │  chat_thread_id ──FK──► chat_threads.id  ON DELETE CASCADE
  │  role, content, metadata   │
  └────────────────────────────┘

  ── the capability ceiling and its narrowing ───────────────────────────────────

  ┌──────────────────────────────┐
  │ connection                   │
  │  id PK · user_id ──FK──► user.id
  │  provider_id 'mcp'|'notion'|'pylon'|'mintlify'
  │  name                        │
  │  metadata jsonb              │
  │    ▼ contains                │
  │      serverUrl, encryptedHeaders, instructions
  │      toolPolicy (NEW)        │
  │        ▼ contains            │
  │          mode, lastReviewedAt, discoveredAt
  │          rules[] ◄── populated by listMcpTools() AT SETUP
  │            ▼ contains        │
  │              toolName, allowed, description, instruction,
  │              requireApproval, pinnedArguments, argumentRules[]
  └──────────────────────────────┘
                 ▲  THE CEILING
      ┌──────────┴───────────────────────────────────┐
      │  effective = policy.rules ∩ frontmatter.mcp_servers
      │  intersect, NEVER union. Enforced in callMcpTool.ts
      │  before the outbound tools/call, not by filtering
      │  the prompt.
      └──────────────────────────────────────────────┘

  Cardinalities
  ─────────────
  documents(agent doc)  1 ──1:N──► agent_executions        via agent_id (logical)
  documents(agent doc)  1 ──1:N──► chat_threads            via agent_id (logical, ACTOR)
  documents(agent doc)  1 ──1:2──► documents (memory)      via path user/agent-{id}/memory/*
  documents(agent doc)  1 ──1:N──► documents (owned)       via path user/agent-{id}/, minus memory/
  documents(agent doc)  1 ──N:M──► documents (touched)     via document_updates.actor_agent_id
  documents(agent doc)  1 ──N:M──► documents (granted)     via frontmatter.files[]
  documents(playbook)   1 ──N:M──► documents (agent docs)  via compiled_playbook ref nodes
  documents(agent doc)  N ──N:M──► documents (agent docs)  via <ref> in a sibling body
  agent_executions      1 ──1:N──► agent_executions        via parent_run_id (self, no FK)
  connection            1 ──N:M──► documents (agent docs)  via frontmatter.mcp_servers[]
  crm_conversations     1 ──1:N──► chat_threads            via context jsonb (SUBJECT)
```

## 4) Implementation phases

Memory lands at 9–10 because correction capture needs the attribution column from Phase 5 and the chat binding from Phase 8. Sharing is last: it is the only phase that widens who can see an agent, and it must not ship before the grants it validates — and the memory-privacy rules it enforces — exist.

### Phase 1 — Invocation sources + the agent read model

**Goal:** "everywhere this agent comes from" is one query, and every fact the workspace needs is reachable from one tRPC namespace.

- [x] Create `apps/server/src/services/agent-workspace/types.ts` with the wire types from §3.3 part 6 — dependency-free (no drizzle, no mastra), mirroring [execution-tree-types.ts](apps/server/src/services/agent-action-queue/execution-tree-types.ts) so `apps/mail` can `import type` it.
- [x] Create `apps/server/src/services/agent-workspace/agent-invocations.ts` with pure `resolvePlaybookSources(compiled: CompiledPlaybook, documentId: string): AgentInvocationSource[]`, walking `global` and every `stages[*]` block array for `{ type:'ref', id }` nodes.
- [x] Join each compiled `webhook` block to its `playbook_webhooks` row ([playbook-webhook.ts:39](apps/server/src/services/playbook/playbook-webhook.ts)) for the URL, enabled flag and last-fired stamp — the webhook row is the one the user acts on.
- [x] Skip `postApiBlocks` entirely in this resolver. They are endpoints the agent calls outward (`renderPostApiEndpoints`, [playbook-renderers.ts:129](apps/server/src/services/playbook/playbook-renderers.ts)) and belong in Connections; treating them as triggers would claim the agent fires when it does not.
- [x] Add `resolveAgentSources(db, { orgId, aopId, agentId, documentId, since })` returning ONE row per invoking agent, carrying `declared` (a bounded `LIKE` over sibling `…/subagents/%` bodies for `<ref id="…"/>` / `[[doc:…]]`) and `runs30d` (from `parent_run_id` on `agent_executions`) — union the two sides by `agentId` so a parent that both declares and fires produces one row, not two.
- [x] Add `resolveAgentInvocationSources(...)` composing playbook + agent sources and attaching `runs30d` from one grouped count query.
- [x] Do **not** add chat or manual-run sources. A chat is listed in Previous Runs; a manual run is a header button, not configuration.
- [x] Create `apps/server/src/services/agent-workspace/outputs.ts` with `getAgentOutputs(db, { orgId, userId, agentId })`; `touched` returns empty until Phase 5.
- [x] Create `apps/server/src/trpc/routes/agent.ts` with `list`, `get`, `getInvocationSources`, `getOutputs`, `getRuns` — all `privateProcedure`, scoped through the existing `getAllSubagentsForAop` merge.
- [x] Register `agent` on the app router beside `aopAgents`.
- [x] Add an `agentId` filter to `GetAgentExecutionsInputSchema` at [agent-executions.ts:52](apps/server/src/trpc/routes/agent-executions.ts) and apply it in `getAgentExecutions`.
- [x] Add a `cedar-cli agent <list|show|sources|outputs|runs>` driver following [cli/trace.ts](apps/server/src/cli/trace.ts) — a thin HTTP client over the running tRPC API, no duplicated logic.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/agent-invocations.test.ts` — a ref in a global cron block, a stage event block, a before-meeting block, a field-change block and a webhook block each resolve; a doc referenced in no block yields `[]`; a doc referenced twice yields two sources.
- [x] Same file — a compiled blob predating `webhookBlocks`/`postApiBlocks` resolves without throwing; an unrecognised block shape yields `kind: 'unknown'` rather than being dropped.
- [x] Same file — a parent that declares AND fires yields one `agent` row, not two; declared-but-never-fired yields `{declared: true, runs30d: 0}`; fires-but-undeclared yields `{declared: false, runs30d: >0}`.
- [x] Same file — a `postApiBlock` naming this agent's ref produces **no** invocation source; a `webhookBlock` produces one carrying its URL and enabled flag.
- [x] `apps/server/src/trpc/routes/__tests__/agent-router-auth.test.ts` — another user's `agentId` is refused; an org-scoped agent resolves for every member.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/agent-invocations.test.ts`

### Phase 2 — Workspace shell: route, four tabs, Output, Config §3

**Goal:** `/agents/:agentId` opens with a working Output tab and the Config page carrying its Instructions section, and an `agent` document opens into it.

- [x] Add `route('/agents/:agentId', '(routes)/agents/[agentId]/page.tsx')` to [routes.ts](apps/mail/app/routes.ts) inside the existing `(routes)/agents` layout.
- [x] Create `apps/mail/modules/agents/components/AgentView.tsx` — header (avatar, name, description, model, enabled toggle, scope chip) over a **four**-tab bar: Output · Config · Memory · Previous Runs. Header writes reuse [AgentDocHeader.tsx](apps/mail/modules/aop/components/AgentDocHeader.tsx) rather than a second path.
- [x] Create `AgentConfigPage.tsx` as **one scrolling page** with a sticky section nav and three anchored sections (`#sources`, `#connections`, `#instructions`); sections 1 and 2 are placeholders until Phases 3 and 7.
- [x] Create `AgentInstructionsSection.tsx` — the same markdown editor `CompanyExplorer` uses for a subagent doc, frontmatter hidden, plus a resolved-grants strip.
- [x] Create `AgentOutputTab.tsx` — owned files grouped by document type, `touched` as a second group, `inConversations` as a link-out count; `memory/` paths excluded.
- [x] Add an `agent` branch to the document render switch in [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx) beside the `table` branch (line 1172) mounting `AgentView`.
- [x] Add "+ New" in the Output tab wired to `files.createFile` / `files.createTable` with the parent fixed to `user/agent-{agentId}/`.
- [x] Delete the unrouted prototype `apps/mail/app/(routes)/agents/page.tsx` and `mock-data.ts` — dead mock data beside a live screen is how the two diverge.

**Tests:**

- [x] `apps/mail/modules/agents/__tests__/AgentView.test.tsx` (jest — `apps/mail` runs jest, not vitest) — four tabs render; a disabled agent renders the disabled chip; `#connections` in the URL scrolls rather than switching tabs.
- [x] `apps/mail/modules/agents/__tests__/AgentOutputTab.test.tsx` — an unknown `documentType` renders as a generic row rather than blank; `inConversations: 0` hides the group; a `memory/` path never appears.
- [x] `pnpm --filter @zero/mail test modules/agents`

### Phase 3 — Config §1 (invocation sources, read-only) and Previous Runs

**Goal:** the surfaces that answer "when does this fire" and "what has it done", reading only — so the playbook cannot be corrupted by a half-built editor.

- [x] Build `AgentSourcesSection.tsx` rendering `AgentInvocationSource[]` as one sorted list of human sentences ("Every weekday at 7:00am", "Spawned by Review Conversation", "Direct chat"), each with its 30-day run count.
- [x] Render `runs30d: 0` on a `playbook` source, or `{declared: true, runs30d: 0}` on an `agent` source, as an explicit "configured, never fired" state — the highest-value diagnostic on this page.
- [x] Render `{declared: false, runs30d: >0}` as "fires, but nothing declares it" with a note that a parent is choosing it at runtime — real behaviour the playbook does not describe.
- [x] Render a `webhook` trigger with its copyable URL, enabled toggle and last-fired time inline; it is the only row the user acts on rather than just reads.
- [x] Put **Run now** in the page header beside the enabled toggle, not in the sources list — it is available for every agent always, so as a row it would be a constant. **(done: `agent.runNow` over `runSingleSubagent`, plus `cedar-cli agent run`.)**
- [ ] Link each `playbook` source to the exact PLAYBOOK.md block it came from. **(not done — the compiled blob carries no source offset back into the doc; needs a block anchor from `compilePlaybook`.)**
- [x] Render a `kind: 'unknown'` source as a raw fallback row rather than hiding it.
- [x] Create `AgentRunsTab.tsx` with Chats / Executions sub-tabs; Executions groups by day and opens the existing waterfall at `/settings/agentExecutions/:runId`.
- [x] Show `enabled: false` as a banner across the whole Config page, not a subtle chip — a disabled agent with five invocation sources is the most confusing state this screen can be in.

**Tests:**

- [x] `apps/mail/modules/agents/__tests__/AgentSourcesSection.test.tsx` — each source kind renders its sentence; `runs30d: 0` renders the never-fired state; an unknown kind renders the fallback row rather than crashing.
- [x] `apps/mail/modules/agents/__tests__/AgentRunsTab.test.tsx` — day grouping is stable across a timezone boundary; a `failed` run renders its status.
- [x] `pnpm --filter @zero/mail test modules/agents`

### Phase 4 — Invocation editing writes through the playbook

**Goal:** a playbook invocation source can be added, retimed or removed from Config §1, with PLAYBOOK.md remaining the only source of truth.

- [x] Add `agent.upsertPlaybookSource` / `agent.removePlaybookSource` that patch the PLAYBOOK.md XML and persist through `aop.saveCompositePlaybook` at [aop.ts:586](apps/server/src/trpc/routes/aop.ts), so the manifest and compiled blob are rebuilt by the existing path.
- [x] Implement the patch as a **targeted edit of the named block**, never a re-serialization of the whole playbook from UI state — a UI that rewrites the document deletes everything the agent authored in it.
- [x] After every write, re-run `resolveAgentInvocationSources` and return the result, so the page renders what the playbook now says rather than what the form submitted.
- [x] Add an optimistic-concurrency guard: reject the patch if the playbook's version moved since load, with a distinguishable error code.
- [x] Support both adding this agent's `<ref id="…"/>` to an existing block and creating a new block.
- [x] Leave `agent` sources read-only: editing a declaration means editing another agent's body, which belongs in that agent's own workspace — link to it instead.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/source-patch.test.ts` — adding a cron source leaves every other block byte-identical; removing the last ref removes the block; parse → patch → compile is idempotent.
- [x] Same file — a stale version is rejected with a distinguishable error code.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/source-patch.test.ts`

### Phase 5 — Per-agent file attribution (G2)

**Goal:** "every file this agent has edited" becomes a real query — and the correction signal Phase 10 needs becomes joinable.

- [x] Add `actor_agent_id` and `actor_run_id` to `document_updates` plus `document_updates_actor_agent_idx` in [documents-schema.ts](apps/server/src/db/documents-schema.ts), with a drizzle migration.
- [x] Read the ambient run scope (the `AsyncLocalStorage` opened by `instrumentToolMap` in [tool-call-timing.ts](apps/server/src/services/agent-action-queue/tool-call-timing.ts)) inside `writeDocument` at [documents/index.ts:361](apps/server/src/services/documents/index.ts) — no tool signatures change.
- [x] Thread `actorAgentId` / `actorRunId` / an `actorLabel` of the agent's name through `writeFileAsYjs` ([writeFileAsYjs.ts:270](apps/server/src/services/document-saving/writeFileAsYjs.ts)), `writeTableAsYjs` ([writeTableAsYjs.ts:365](apps/server/src/services/document-saving/writeTableAsYjs.ts)) and `applyUpdate` ([applyUpdate.ts:411](apps/server/src/services/document-saving/applyUpdate.ts)).
- [x] Populate the `touched` half of `getAgentOutputs`; keep `deleted_at IS NULL`, exclude `conversation/` and `memory/` paths, dedupe to one row per document, cap, order by `ended_at DESC`.
- [x] Do **not** backfill. Historical rows carry no attribution and never can; the tab says "since \<deploy date\>".

**Tests:**

- [x] `apps/server/src/services/document-history/__tests__/capture.test.ts` — extend: an agent write records `actor_agent_id`; a human write leaves it null; a change of `actor_agent_id` opens a new session (the actor-changed rule at [capture.ts:95](apps/server/src/services/document-history/capture.ts)).
- [x] `apps/server/src/services/agent-workspace/__tests__/outputs.test.ts` — `touched` excludes deleted docs, `conversation/` paths and `memory/` paths, and dedupes a document edited in ten sessions to one row.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/outputs.test.ts src/services/document-history/__tests__/capture.test.ts`

### Phase 6 — MCP tool policy, captured at connection setup (G3)

**Goal:** connecting an MCP server enumerates its tools and asks which are allowed, with a per-tool instruction — before the connection is saved.

- [x] Add `McpToolPolicy` / `McpToolRule` / `McpArgumentRule` and `toolPolicy` on `McpConnectionMetadata` in [types.ts](apps/server/src/services/integrations/mcp/types.ts), per [mcp-tool-allowlist.md](apps/server/docs/design/mcp-tool-allowlist.md).
- [x] Add `integrations.listMcpConnectionTools({ connectionId })` and a pre-save `integrations.probeMcpServerTools({ serverUrl, authorizationHeader })`, both over `listMcpTools` at [mcp-client.ts:410](apps/server/src/services/integrations/mcp/mcp-client.ts) with `getFreshMcpHeaders` ([mcp-token-refresh.ts](apps/server/src/services/integrations/mcp/mcp-token-refresh.ts)).
- [x] Have `addMcpConnection` ([integrations.ts:2424](apps/server/src/trpc/routes/integrations.ts)) probe on connect and seed `toolPolicy.rules` with `allowed: false`, stamping `discoveredAt`.
- [x] Backfill `mode: 'allow_all'` onto every existing connection so the deploy is a behaviour no-op; new connections start `allowlist`.
- [x] Add `apps/server/src/services/integrations/mcp/tool-policy.ts` — pure `evaluateMcpToolCall(policy, toolName, args)`; a missing required field fails, `pinnedArguments` apply after validation, an uncompilable `matches` fails **closed**.
- [x] Enforce it in [callMcpTool.ts](apps/server/src/mastra/tools/integrations/callMcpTool.ts) before `callMcpClient`, recording the decision via `logToolCall`.
- [x] Write denials for the model, not the log: name the tool, the connection, and where to enable it.
- [x] Extend [system-row.tsx](apps/mail/modules/integrations/systems/system-row.tsx) with a tool checklist, per-tool instruction field, argument-constraint chips, `requireApproval` toggle, a **New — not enabled** badge against `lastReviewedAt`, and a "Review permissions" banner on `allow_all` rows.  *(final frontend pass)*
- [x] Drop `authorizationHeader` from [IntegrationNode.tsx](apps/mail/modules/documents/playbook/IntegrationNode.tsx) and from stored playbook doc bodies — a credential must never live in a document the agent reads and can quote.  *(final frontend pass)*

**Tests:**

- [x] `apps/server/src/services/integrations/mcp/__tests__/tool-policy.test.ts` — a missing required field fails; `pinnedArguments` overwrite agent-supplied values; an unmatched tool is denied; `allow_all` passes; an uncompilable `matches` fails closed.
- [x] Integration test against the existing fake-MCP fixtures: a denied call never reaches an outbound `tools/call`, and the denial is logged.
- [x] Regression: an `allow_all` connection behaves identically before and after enforcement.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/mcp/__tests__/tool-policy.test.ts`

### Phase 7 — Per-agent grants and Config §2 (G4)

**Goal:** each agent holds an explicit grant that can only ever narrow the connection's ceiling, edited on the same page as its triggers and instructions.

- [x] Add `mcp_servers`, `files`, `boards`, `chat_enabled`, `memory_enabled` to `SubagentFrontmatter` and its parser at [reference-resolver.ts:449](apps/server/src/services/playbook/reference-resolver.ts), reusing the existing YAML block-list handling.
- [x] Add `apps/server/src/services/agent-workspace/grants.ts` — `resolveAgentGrants(frontmatter, connections)` **intersecting** the agent grant with each connection's `toolPolicy`.
- [x] Narrow `buildUserMcpServers` ([user-mcp-servers.ts:31](apps/server/src/mastra/workflows/chat/harness/user-mcp-servers.ts)) by the resolved grant when a thread is agent-bound, and narrow the automation dispatch path the same way.
- [x] Merge the frontmatter grant with the static defaults in [subagent-tool-allowlists.ts](apps/server/src/mastra/tools/subagent-tool-allowlists.ts), keeping `assertAllowlistResolves()` as the typo guard.
- [x] Preserve the `spawn-subagent` exclusion documented there: a spawned child is not bound by the parent's grant, which is how the 2026-07-21 duplicate-draft escape hatch opened.
- [x] Treat an **absent** `mcp_servers` key as today's behaviour and an **empty list** as "nothing", so un-migrated agents do not silently lose capability on deploy.
- [x] Build `AgentConnectionsSection.tsx` into the Config page at `#connections` — the four defaults with status chips, then MCP connections rendered as `allowedByConnection ∩ granted`, with the per-tool instruction shown read-only (edited on the connection, in Settings).
- [x] ~~Add "Add MCP connection" deep-linking to Settings → Integrations rather than duplicating the connect flow.~~ **Superseded by Phase 15** — the deep link was the right ownership call and the wrong control; the picker now resolves in place without duplicating the connect flow.
- [x] List the agent's compiled `postApiBlocks` in this section as outbound endpoints (name, URL, when-to-call), read-only and edited in the playbook. They are things the agent calls, so Connections is where they belong — not the invocation-sources list, where they would falsely read as triggers.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/grants.test.ts` — a grant naming a connection-denied tool still denies; an empty `mcp_servers` grants nothing; an absent `mcp_servers` preserves today's behaviour.
- [x] `apps/server/src/mastra/tools/__tests__/capability-grant.test.ts` — a declared grant resolves; an undeclared tool is absent; an unknown id warns rather than silently dropping a capability.
- [x] Assert a grant cannot widen its ceiling, and that a granted agent cannot widen itself by spawning a child.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/grants.test.ts`

### Phase 8 — Chat bindings: actor and subject (G6, G7)

**Goal:** a chat can be a conversation *with* an agent, the conversation it is *about* becomes queryable, and neither can be orphaned by a merge.

- [x] Add `chat_threads.agent_id` + `idx_chat_threads_agent_id` in [chat-schema.ts](apps/server/src/db/chat-schema.ts) with a migration; accept it on `chat.createThread` at [chat.ts:108](apps/server/src/trpc/routes/chat.ts).
- [x] Enforce immutability: `chat.updateThread` refuses an `agentId` change once the thread has any message; retargeting is a new thread.
- [x] Add the raw-SQL expression index `((context->'primaryConversation'->>'id'))` on `chat_threads WHERE deleted_at IS NULL`, and a `chat.listThreadsForConversation` procedure over it.
- [x] Repoint chat threads inside `repointConversationDocuments` at [conversation-repoint.ts:93](apps/server/src/services/documents/conversation-repoint.ts), in the same transaction as the documents.
- [x] Extend `assertNoConversationDocumentsLeftBehind` at [conversation-repoint.ts:274](apps/server/src/services/documents/conversation-repoint.ts) to assert no chat thread still points at the losing conversation id.
- [x] Add `AGENT_CHAT_SHELL` in `apps/server/src/mastra/agents/agent-chat-shell.ts` — the UI/tool contract plus one framing line, nothing domain-specific.
- [x] Branch `runChatViaAgentSdk` ([run-chat-agent-sdk.ts](apps/server/src/mastra/workflows/chat/run-chat-agent-sdk.ts)): when bound, `systemPrompt = AGENT_CHAT_SHELL + <doc body>`, `mcpServers` narrowed by the Phase 7 grant, `options.agents = { general-purpose }` only. (The memory preamble and folder contents join this composition in Phase 10.)
- [x] Exclude the bound agent from `buildSubagentDefinitions` ([subagents.ts:57](apps/server/src/mastra/workflows/chat/harness/subagents.ts)) so it cannot `Task`-delegate to itself.
- [x] Stamp `agent_executions.agent_id` with the bound agent for every execution the thread produces.
- [x] Respect `chat_enabled: false` by hiding the chat affordance and refusing the binding server-side.
- [x] Add `DEBUG_MODE_SECTION` and `AgentDebugRail.tsx` — an embedded chat panel on Config, Memory and Previous Runs sharing one thread per agent.
- [x] Degrade gracefully: an `agent_id` that no longer resolves falls back to the general prompt with a banner, never a 500.

**Tests:**

- [x] `apps/server/src/mastra/workflows/chat/__tests__/agent-bound-chat.test.ts` — a bound thread's system prompt contains the doc body and not the general chat prompt; the bound agent is absent from `options.agents`; an unresolvable `agent_id` falls back.
- [x] Same file — `mcpServers` for a bound thread equals the grant intersection, not the user-global set.
- [x] `apps/server/src/trpc/routes/__tests__/chat-agent-binding.test.ts` — `chat_enabled: false` refuses the binding; another user's agent cannot be bound; an `agentId` change after the first message is refused.
- [x] `apps/server/src/services/documents/__tests__/conversation-repoint-chat.test.ts` — a merge repoints every chat thread pointing at the losing id, and the left-behind assertion fails loudly if one is missed.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/mastra/workflows/chat/__tests__/agent-bound-chat.test.ts src/services/documents/__tests__/conversation-repoint-chat.test.ts`

### Phase 9 — Memory: a folder of markdown files the agent owns (G11, part 1)

**Goal:** every agent has an internal folder, separate from its outputs, that it can write to and the user can read and edit.

- [x] Create `apps/server/src/services/agent-memory/memory-paths.ts` — `agentMemoryDirPath(agentId)` = `user/agent-{agentId}/memory`, plus `AGENT_MEMORY_FILES` (`corrections`, `preferences`, `notes`) and `AGENT_MEMORY_MAX_CHARS`. Built on `agentNamespacePath` at [convention-paths.ts:94](apps/server/src/services/documents/convention-paths.ts).
- [x] Create `apps/server/src/services/agent-memory/memory-store.ts` — `ensureMemoryFiles` (creates the three empty `document` files with a one-line heading), `readMemory` (concatenates them for injection, capped) and `memoryCharCount`. All over the existing `writeDocument` / `readDocument`; no new write path.
- [x] **Add no new tool.** The agent reaches its memory with the `write-document` tool it already has, in `append` mode (`DOCUMENT_WRITE_MODE.APPEND`, [document-types.ts](apps/server/src/services/documents/document-types.ts)). What is new is the grant and the preamble, not a verb.
- [x] Grant every memory-enabled agent `files: ['user/agent-{agentId}/memory/: rw']` implicitly at dispatch, resolved from the ambient run scope — an agent reaches its own folder and no other agent's, and cannot get the path wrong because it never supplies it.
- [x] Exclude `memory/` from `getAgentOutputs` (already asserted in Phase 5's test) and hide it from the file tree in [CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx) — it has its own tab.
- [x] Add `agent.getMemory` (the three files with content and char counts) and reuse the existing document write path for edits — the Memory tab needs no bespoke mutation.
- [x] Create `AgentMemoryTab.tsx` — the files as three editable markdown panes with a char count against the cap, and a "+ New file" that creates another `.md` in the same folder.
- [x] Show empty files as "Nothing learned yet — corrections and chats will show up here", not a blank editor.
- [x] Honour `memory_enabled: false`: no files are created, no grant is issued, and the tab explains the agent is deterministic by configuration.
- [x] Add `cedar-cli agent memory <read|append|clear>` so the folder is drivable headlessly before any capture exists.

**Tests:**

- [x] `apps/server/src/services/agent-memory/__tests__/memory-store.test.ts` — `ensureMemoryFiles` creates exactly three `document`-type files at the right paths and is idempotent; `readMemory` concatenates them and truncates at the cap rather than silently dropping a whole file.
- [x] Same file — an agent cannot write to another agent's memory path even when handed one; `memory_enabled: false` creates nothing.
- [x] `apps/mail/modules/agents/__tests__/AgentMemoryTab.test.tsx` — empty files render the empty state; an edit routes through the ordinary document write path.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-memory/__tests__/memory-store.test.ts`

### Phase 10 — Memory: universal capture, injection, and self-reflection (G11, part 2)

**Goal:** every agent appends to its own memory without being told to, reads it back on every run, and tidies it up periodically.

- [x] Create `AGENT_MEMORY_PREAMBLE` in `apps/server/src/mastra/agents/agent-memory-preamble.ts` — one short constant naming the three files, saying when to append to each, and **explicitly that one-off task parameters are not memories**. "Always cc Sam on renewals" belongs in `preferences.md`; "cc Sam on this one" belongs nowhere. Without that line the files fill with task noise inside a week.
- [x] Compose it at **both** call sites so no agent can be missed: the instruction assembly in `runAgent` at [automations.ts:294](apps/server/src/services/aop/automations.ts), and `AGENT_CHAT_SHELL` from Phase 8.
- [x] Inject `readMemory(agentId)` immediately after the preamble and before the doc body, capped at `AGENT_MEMORY_MAX_CHARS`.
- [x] Add `captureCorrection` in `apps/server/src/services/agent-memory/capture-correction.ts` — detect the adjacent `[origin:'agent', actor_agent_id:X, toSeq:N] → [origin:'human', fromSeq:N]` pair on one document, reconstruct both versions through the existing history machinery, diff, ask a small model for the one-sentence lesson, and **append one bullet to `corrections.md`**.
- [x] Run capture **out of band** on the document-history write path, capped at one memory per document per day, so an afternoon of editing yields one bullet rather than forty.
- [x] Add a weekly `reflect` pass (reusing the existing cron dispatch, not a new scheduler) that reads the agent's recent executions and its own memory files and **rewrites them condensed** — merging duplicates, dropping what has been contradicted, keeping the files under the cap. Tidying is a rewrite the agent performs, not a compaction algorithm we maintain.
- [x] Let `reflect` propose a patch to the agent's instructions as a plain accept/decline card in the Memory tab. **Never auto-apply it** — an agent that rewrites its own instructions with no audit trail is exactly the drift the playbook-as-source-of-truth rule exists to prevent.
- [x] Append a declined proposal to `preferences.md` ("the user declined X") or the next reflection proposes it again forever.
- [x] Add `cedar-cli agent memory reflect --agent <id> [--dry-run]`, where the dry run prints the rewritten files without writing them.

**Tests:**

- [x] `apps/server/src/services/agent-memory/__tests__/capture-correction.test.ts` — an agent→human adjacent pair appends one bullet to `corrections.md`; a human→human pair appends none; an agent session with no following human session appends none; the per-doc-per-day cap holds across ten edits.
- [x] Same file — a correction on a document with no `actor_agent_id` (pre-Phase-5 history) is skipped silently rather than attributed to the wrong agent.
- [x] `apps/server/src/services/agent-memory/__tests__/reflect.test.ts` — a rewrite keeps the files under the cap; a declined proposal lands in `preferences.md` and is not re-proposed on the next run.
- [x] `apps/server/src/mastra/agents/__tests__/memory-preamble.test.ts` — both the automation path and the chat path carry the preamble and the memory contents; `memory_enabled: false` carries neither.
- [x] Instruction eval via [playbook-instruction-eval](apps/server/.claude/skills/playbook-instruction-eval): given a chat where the user states a standing preference, the agent appends to `preferences.md`; given a one-off task parameter, it does not. This is the behavioural claim the phase rests on.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-memory/__tests__/`
### Phase 11 — Real agent creation, and deleting the misnamed one (G8)

**Goal:** "+ New agent" creates an agent, and nothing in the codebase claims to create an agent while creating a folder.

- [x] Delete `createAgent` from [file-system/index.ts:420](apps/server/src/services/file-system/index.ts) — its whole body is `return createFolder(scope, input)`.
- [x] Delete the `createAgent` procedure from [files.ts:489](apps/server/src/trpc/routes/files.ts) and its import; it has no caller.
- [x] Remove the `createAgent: vi.fn()` stub from [files-router-auth.test.ts:66](apps/server/src/trpc/routes/__tests__/files-router-auth.test.ts).
- [x] Add `agent.create({ name, description?, model? })` over `authorSubagentDoc` at [author-subagent.ts:87](apps/server/src/services/playbook/author-subagent.ts), minting an `agent_id` and writing **blank-line-separated** frontmatter (the round-trip requirement in [agent-document-type.md](apps/server/docs/wiki/agent-document-type.md)).
- [x] Land the new agent on `/agents/:agentId` with Config and Memory stating plainly that it has no invocation sources and nothing learned yet.
- [x] Add `cedar-cli agent create`.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/agent-create.test.ts` — a created doc round-trips through `parseFrontmatter` with its `agent_id` intact; `documentType` is `agent`; the path matches `isSubagentDocPath` at [convention-paths.ts:255](apps/server/src/services/documents/convention-paths.ts).
- [x] Same file — two creations with the same display name produce distinct filenames and distinct `agent_id`s; no memory documents are created eagerly.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/agent-create.test.ts src/trpc/routes/__tests__/files-router-auth.test.ts`

### Phase 12 — Home screen agent list, under a collapsed agenda

**Goal:** agents are reachable from the first screen without scrolling past the day's agenda.

- [x] Create `apps/mail/modules/home/components/AgentsList.tsx` — agents as **rows**, not a horizontal card strip: one line each with avatar, name, last-run relative time and an enabled dot, over `agent.list`. A row scans in one column; a card strip makes ten agents a horizontal scroll nobody finds the end of.
- [x] Mount it in `AgentHomeBelowChat` at [AgentHomeHero.tsx](apps/mail/modules/home/components/AgentHomeHero.tsx) **below** `HomeAgenda`, not above it.
- [x] Collapse `HomeAgenda` by default, with its item count in the header (`▸ Daily Agenda (3)`) so a collapsed section still says how much is in it. Expanded/collapsed persists per user via the existing user-settings blob, so the choice survives a reload.
- [x] Keep the agenda's own data fetch as it is — collapsing is a render decision, not a reason to skip the query, or expanding would stall on a spinner every time.
- [x] Add per-agent run counts, `lastRunAt` and `memoryChars` to `agent.list` from one grouped query each, not N queries.
- [x] Clicking a row opens `/agents/:agentId`; a secondary action starts a chat bound to that agent.
- [x] Show an org-scoped agent with a team chip so a shared agent is distinguishable at a glance.
- [x] Leave the list flat in this phase — Phase 14 groups it into folders. Shipping the grouping and the list in one phase means neither can be reverted alone.

**Tests:**

- [x] `apps/mail/modules/home/__tests__/AgentsList.test.tsx` — renders one row per agent; zero agents shows the create affordance, not an empty strip; an org-scoped agent renders the team chip.
- [x] `apps/mail/modules/home/__tests__/HomeAgenda.test.tsx` — the agenda renders collapsed by default with its count; expanding persists and rehydrates; a collapsed agenda still issues its query.
- [x] `pnpm --filter @zero/mail test modules/home`

### Phase 13 — Sharing, duplication and team access (G9, G10)

**Goal:** an agent can be duplicated, published to the team, and pointed at specific files — with the grant validation and the memory-privacy rules that make publishing safe.

- [x] Add `files:` grant resolution to `apps/server/src/services/agent-workspace/grants.ts` — paths or doc ids with `: rw` / `: r` suffixes, the same grammar as `boards:` in [wiki/board-documents.md](apps/server/docs/wiki/board-documents.md).
- [x] Enforce the `files:` grant inside the document tools' call sites, with a denial naming the file, the grant held, and where to widen it — not a log line.
- [x] Add `agent.duplicate({ agentId, newName })` — copies the doc to a new filename and **mints a new `agent_id`**; assert the new id differs, since it keys the output namespace, the memory namespace and `agent_executions`.
- [x] **Duplicate starts with an empty memory folder.** Copy no `memory/*.md`; state it in the confirm dialog. Inheriting memory is how a new agent arrives pre-loaded with someone else's wrong conclusions.
- [x] Add `agent.publishToOrg({ agentId, includeMemoryFiles?: string[] })` — copies the subagent doc to `organisation/playbooks/{orgAopId}/subagents/{name}.md`, relying on the user-shadows-org merge in [subagents.ts:32](apps/server/src/services/playbook/subagents.ts) for the author's private override.
- [x] **Publishing shares no memory by default.** `includeMemoryFiles` names the files to copy, and only behind a preview of their exact text — `corrections.md` records one person's corrections and may quote deal specifics they never meant to publish.
- [x] Document and test that teammates of a published agent learn independently at their own `user/agent-{agentId}/memory/` — one agent document, N memory folders.
- [x] Validate grants on publish: refuse when any `files:` entry names a `user/`-scoped path, listing every offending path.
- [x] Add `agent.unpublishFromOrg({ agentId })` deleting the org copy, and state plainly that teammates lose the agent while their runs, outputs and memory are untouched — those are keyed by `agent_id`, not by the doc.
- [x] Add the share menu to the workspace header: Publish to team · Duplicate · Copy link, with a `Personal` / `Team` scope chip beside the agent name.
- [x] Add a **Files** section to Config §2 listing the `files:` grants with an add/remove picker over the user's document tree.
- [x] Add `cedar-cli agent <duplicate|publish|unpublish>`.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/publish-duplicate.test.ts` — duplicate mints a distinct `agent_id` and path; publish writes an org-scoped doc; a user doc of the same filename still shadows it in `getAllSubagentsForAop`.
- [x] Same file — duplicate copies no memory files; publish without `includeMemoryFiles` copies none; with it, copies exactly the named files.
- [x] Same file — two users of one published agent write to two distinct memory folders and never read each other's.
- [x] Same file — publish refuses when a `files:` grant names a `user/` path, and the error names every offending path; unpublish leaves the author's doc, executions, outputs and memory intact.
- [x] `apps/server/src/services/agent-workspace/__tests__/file-grants.test.ts` — an ungranted path is refused at the tool call site; a `: r` grant refuses a write; an absent `files:` key preserves today's behaviour.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/publish-duplicate.test.ts src/services/agent-workspace/__tests__/file-grants.test.ts`

### Phase 14 — Agent folders: one level, seeded from the template

**Goal:** agents arrive already filed, so a fresh account does not open onto seven system agents with no sense of which ones the user is meant to talk to.

The three folders are one axis collapsed from two — *what scope does it work at, and do you talk to it*:

| Folder | What belongs there | Seeded with |
|---|---|---|
| **In-conversation** | Fires per deal; its output lands in `conversation/{id}/…`, not in its own namespace. You rarely open it and never chat with it. | `crm-updater`, `strategist`, `next-steps`, `meeting-prep`, `deal-drafter` |
| **Background** | Account-level, scheduled or event-driven, no chat. It runs whether or not you are looking. | `daily-agenda`, `inbound-email-notifier`, `pipeline-review` |
| **Active** | The agents you open, chat with, and build. | **empty** |

Active starting empty is deliberate and is the point of the split: a new account's agent list should read "here are seven things running for you in the background, and nothing you own yet", not present `crm-updater` as something to go and talk to. The folder fills as the user builds.

- [x] Add `folder?: AgentFolder` and `AGENT_FOLDERS` to `SubagentFrontmatter` and its parser at [reference-resolver.ts:449](apps/server/src/services/playbook/reference-resolver.ts).
- [x] **Keep it in frontmatter, not in the path.** Nesting `…/subagents/{folder}/{name}.md` breaks `isSubagentDocPath` at [convention-paths.ts:255](apps/server/src/services/documents/convention-paths.ts) (`/subagents\/[^/]+$/`), so the doc silently stops being stamped `documentType: 'agent'`; and the user-shadows-org merge keys on `path.split('/').pop()` ([subagents.ts:32](apps/server/src/services/playbook/subagents.ts)), so the same agent filed under two folders would stop shadowing itself.
- [x] Resolve `folder` in `getAllSubagentsForAop` and surface it on `AgentSummary`, defaulting an absent **or unrecognised** value to `active` — an agent must never vanish from the list because of a typo in a frontmatter string.
- [x] Set `folder:` in each default agent template under [agent-defaults](apps/server/src/services/playbook/agent-defaults) per the table above, so a newly seeded account is filed correctly with no backfill step.
- [x] Default `agent.create` to `folder: 'active'` — an agent the user just made by hand is by definition one they intend to work with.
- [x] Add `agent.setFolder({ agentId, folder })` writing through `applySubagentFrontmatterPatch` at [subagent-frontmatter.ts:54](apps/server/src/services/aop/subagent-frontmatter.ts) — the same patch path the header already uses, so there is one writer for frontmatter.
- [x] Group `AgentsList.tsx` by folder, in the fixed order Active → In-conversation → Background, each with a count and independently collapsible. Active expanded by default; the other two collapsed.
- [x] Render an empty Active folder as "No agents of your own yet — create one", never as a hidden or missing section.
- [x] Add a folder picker to the agent workspace header beside the scope chip, and drag-to-folder in the home list.
- [x] Backfill existing users' subagent docs by filename against the same table, treating anything unrecognised as `active` rather than guessing from its triggers — a wrong guess is worse than the default, because the user cannot tell it was a guess.
- [x] Add `cedar-cli agent folder <agentId> <folder>` and include `folder` in `cedar-cli agent list` output.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/agent-folders.test.ts` — every seeded default resolves to the folder in the table above; an absent `folder:` resolves to `active`; an unrecognised value resolves to `active` rather than throwing or dropping the agent.
- [x] Same file — `setFolder` round-trips through `parseFrontmatter` and leaves every other frontmatter key byte-identical; the doc path is unchanged, so `isSubagentDocPath` still matches and `documentType` stays `agent`.
- [x] Same file — an org-published agent and a user doc of the same filename filed in different folders still shadow correctly (the merge keys on filename, not folder).
- [x] `apps/mail/modules/home/__tests__/AgentsList.test.tsx` — extend: groups render in fixed order with counts; Active is expanded and the others collapsed by default; an empty Active renders its create prompt rather than disappearing.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/agent-folders.test.ts`

### Phase 15 — Adding a connection resolves inside the agent (supersedes Phase 7's deep link)

**Goal:** "+ Add" on Config §2 answers the question it was asked — give THIS agent something to reach — without leaving the agent, and without the workspace ending up with two copies of the same server.

Phase 7 deep-linked "+ Add" to `/settings/connections` on the reasoning that a connection is owned there and duplicating the connect flow is how one server becomes two. The ownership call was right; the control was not. You are on this screen because you want this agent to reach something, and the link navigates away, drops the question, and lands you on a page with no idea which agent sent you — so you connect the server, walk back, and then grant it. And granting was the part the link could never do at all.

What the modal duplicates is nothing: it calls the same `integrations.addMcpConnection` / `integrations.initiateOAuth` / `integrations.setMcpToolPolicy` mutations Settings calls, and reuses `McpConnectionForm` verbatim. What it stops duplicating is the walk.

**Three layers of persistence, three visible controls, one dialog:**

| Layer | Where it lives | Scope |
|---|---|---|
| the connection | `connection` row, `providerId ∈ MCP_PROVIDER_IDS` | the user — appears at `/settings/connections` immediately, because it is the same row |
| the agent grant | `mcp_servers:` frontmatter on this agent's doc | this agent |
| the org share | `connection.metadata.orgShared` | every member of the owner's organization |

**The flag that makes the middle layer honest.** An agent whose `mcp_servers:` key is ABSENT inherits every server the user has connected. That is correct for the servers that predate grants, and wrong for one added from a single agent's modal: it would land on every other agent the moment it was created, and "scoped to this agent" would be decoration. So a connection created here is stamped `metadata.requiresAgentGrant`, and `inherit` no longer covers it — every other agent gets the OPTION (the row appears in its Connections list, ungranted) and none gets the TOOL.

- [x] `McpConnectionMetadata.requiresAgentGrant` + `.orgShared` ([mcp/types.ts](apps/server/src/services/integrations/mcp/types.ts)). Both absent/false on every existing row, so the deploy is a behaviour no-op.
- [x] `grantsServer` / `grantsTool` take an optional set of names that `inherit` does not cover ([grants.ts](apps/server/src/services/agent-workspace/grants.ts)). Omitting it is the pre-existing behaviour, verbatim.
- [x] `listVisibleMcpConnections` ([mcp/visible-connections.ts](apps/server/src/services/integrations/mcp/visible-connections.ts)) — one reader for MINE ∪ ORG-SHARED, used by Settings, the agent Connections page and the chat harness alike. Own rows win a name collision; ownership never moves, so a teammate can see and call a shared server but not edit or unshare it.
- [x] `integrations.listMcpConnections` and `agent.getConnections` read through it; `buildUserMcpServersWithMeta` returns the server map plus the `requiresGrant` name set, and `narrowMcpServersToGrant` takes it as an optional third argument.
- [x] `agent.getMcpGrants` / `agent.setMcpGrants` — mirrors `getFileGrants`/`setFileGrants`, writing through `applySubagentFrontmatterPatch`. `mcp_servers` joins `LIST_FRONTMATTER_KEYS`.
- [x] **A multi-tool entry cannot be written by this path.** The inline list separator is a comma and so is the tool-list separator, so `stripe: a, b` would be written as one grant and read back as two. `setMcpGrants` rejects a comma with a message naming the entry, rather than the serialiser throwing a generic one or a grant silently meaning something else. Whole-server and single-tool entries round-trip exactly.
- [x] `integrations.setMcpConnectionScope({ connectionId, requiresAgentGrant?, orgShared? })` — merges into metadata, never replaces it (the row also carries encrypted headers and the tool policy). Owner-only. Needed because an OAuth provider's row is written by the `/oauth/:provider/callback` handler, so the modal's only chance to stamp scope is when the popup reports success.
- [x] **One picker, two homes.** `apps/mail/modules/integrations/mcp-connection-picker.tsx` renders the list (connected servers first, then the OAuth providers not yet connected, then "Custom MCP server" opening `McpConnectionForm` with its probe + tool checklist), owns the OAuth popup handshake, and carries the owner-only "Share with organization" toggle. The differences between its two callers are PROPS, not forks:
  - `AgentAddConnectionDialog.tsx` — passes `grantOnly`, a `rowAction` rendering Grant/Granted, and an `onConnected` that grants whatever was just added to the agent you are standing in.
  - `mcp-add-connection-dialog.tsx` — Settings' "Add connection", `grantOnly={false}`.
- [x] **`grantOnly` differs by caller on purpose.** From an AGENT it is true: you added this for one agent, so the others get the row, not the capability. From SETTINGS it is false: that is the workspace-wide screen, and a server added there behaves like every server added there before — reachable by any agent that inherits. Adding this distinction is what stops "add it in Settings" and "add it on the agent" from being the same act with two different meanings.
- [x] Settings → Connections → Extra context gains the same picker as an "Add connection" dialog, alongside (not instead of) the full manager below it — the manager is still where credentials are edited, the tool checklist reviewed, and a connection disconnected.
- [x] `AgentConnectionsAddButton` opens the dialog when it has an `agentId`, and falls back to the Settings link when it does not — never a dialog with nothing to grant to.
- [x] A connected-but-ungranted MCP row reads **Off**, not On. "On" against a server the agent cannot call is a lie, and it is exactly the gap the picker exists to close.
- [x] `utils/mcp-grants.ts` owns the three-state read (ABSENT ≠ EMPTY) and the materialise-before-you-change rule, because writing `[name]` on the first tick of an inheriting agent silently REVOKES every other server it had.
- [x] **Fixed a control that could not succeed** (introduced by widening `listMcpConnections` to org-shared rows). Settings rendered Edit and Delete on every row it listed, but `updateMcpConnection` / `deleteMcpConnection` both filter `providerId = 'mcp'` AND `userId = me` — so those buttons appeared on OAuth provider rows and on teammates' shared rows, where their only possible outcome was "MCP connection not found". `mcp-connection-scope.ts` mirrors the server's WHERE clause as `canManageMcpConnection` / `canShareMcpConnection`, and the card renders against it. A control that cannot succeed is worse than an absent one: it reads as a bug in the connection rather than as a boundary.
- [x] Settings rows show what they are: the provider id, `Shared with org` / `Shared by a teammate`, and `Per-agent only` for a `requiresAgentGrant` row — otherwise the only symptom of that flag is an agent whose tool never fires.

**Tests:**

- [x] `apps/server/src/services/agent-workspace/__tests__/grants.test.ts` — an inheriting agent reaches an ordinary server and NOT one requiring an explicit grant; an agent that names it does; omitting the set is byte-identical to the old behaviour; the flag never widens a scoped grant.
- [x] `apps/mail/modules/agents/__tests__/mcp-grants.test.ts` — an undefined grant reads as inheriting rather than empty; the first tick materialises rather than revoking; a hand-authored per-tool entry survives a checkbox verbatim.
- [x] `apps/mail/modules/agents/__tests__/AgentConnectionsSection.test.tsx` — Add resolves in place with an agent and links to Settings without one; an ungranted connected server reads Off.
- [x] `apps/mail/modules/integrations/__tests__/mcp-connection-scope.test.ts` — an OAuth row and a teammate's shared row are not manageable; sharing is ownership-only, whatever the provider.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/grants.test.ts`
- [x] `timeout 300 pnpm --filter @zero/mail exec jest modules/agents/__tests__/mcp-grants.test.ts modules/agents/__tests__/AgentConnectionsSection.test.tsx`

**Not done here:** the automation dispatch path (`run-single-subagent`) does not build an `mcpServers` map at all today, so there is no second enforcement point to update — the chat harness is the only one. When that path grows MCP access it must take `requiresGrant` the same way.

### Phase 16 — The agent's own webhook, and no navigation off the config page

**Goal:** an agent can be given an inbound webhook without leaving it, and nothing on its Config page navigates anywhere.

#### 16a — `agent.createWebhookSource`

A webhook trigger is **half a record in two places at once**: a `playbook_webhooks` row holding the token the public URL is addressed by, and a `<trigger type="webhook" id="…">` block in PLAYBOOK.md carrying this agent's `<ref>`. Either alone is inert — a row nothing refs fires nothing, a block with no row can never be reached. That asymmetry is why the source form offered `webhook` as a **disabled** option pointing at Connections: the form could write one half and not the other, and `renderTriggerOpenTag` refused rather than mint a trigger that could never fire.

The refusal was right about the danger and wrong about the conclusion. One call can write both.

- [x] `renderTriggerOpenTag` writes `<trigger type="webhook" id="{webhookId}" source-id="{src-…}">`. The guard is not removed, it is **restated as a precondition**: a falsy `webhookId` still throws `UNSUPPORTED_TRIGGER`, because the id is the proof a row already exists. Note the two ids mean different things and both matter — `id` is the `playbook_webhooks` row (what `triggerMatches` and the compiler key on), `source-id` is the block's own identity among same-shaped blocks.
- [x] `agent.createWebhookSource({ agentId, label?, expectedVersion })` — registers the row through the SAME `registerPlaybookWebhook` service `aop.createPlaybookWebhook` uses, then patches the playbook, **and deletes the row if the patch fails**. Order matters: the reverse would leave a live public endpoint addressing an agent the playbook never learned about, and skipping the cleanup would accumulate a dead endpoint every time someone lost the concurrency race.
- [x] Always `scope: 'user'`. An org-scope endpoint fires for the whole organisation and is an admin act with its own surface — not something a per-agent button mints.
- [x] `NewSourceRequest` splits the form's output into `{kind:'trigger'}` and `{kind:'webhook'}`, because they are two different acts against two different procedures. Collapsing them into one optional-field shape would put that difference in a runtime check instead of in the type.
- [x] `AgentWebhookPanel` answers the three questions that always follow a URL — what do I POST, does it reach the agent, did anything happen — where the URL is: **Copy URL**, **Copy cURL** (Postman, Insomnia and Newman all import a cURL command directly, so that import IS the integration and Cedar needs no knowledge of those tools), and **Send test request** via the existing `aop.testFirePlaybookWebhook`, which runs the real dispatch path rather than a mock.
- [x] The panel reports the boring failure loudest: dispatch returning nothing, which almost always means no block references the row — the exact mistake `createWebhookSource` now makes impossible, and which otherwise presents as a silent no-op.
- [x] Passed as a **render prop**, so `AgentSourcesSection` still renders with no tRPC provider in scope.
- [x] `cedar-cli agent webhook <agentId> [--label <l>]` — reads the version, creates, prints the URL and a runnable cURL.

#### 16b — Nothing on an agent's Config page navigates

- [x] The last link was Connect on a disconnected **default family** (Gmail/Slack/LinkedIn/Meetings). `AgentFamilyConnectDialog` renders **the same cards Settings renders** — `SlackIntegrationCard`, `LinkedInIntegrationCard`, `MeetingIntegrationCard` — so the two screens cannot drift and a provider added to Settings appears here for free. Gmail is the exception only because it has no card: an email account is linked through better-auth's `linkSocial`, a redirect rather than a component.
- [x] That redirect returns to the **agent**, not to Settings — it was the one place the flow could still lose the question.
- [x] `isAgentConnectionFamily` narrows `AgentConnection.key` (a `string` on the wire) with a real check. A fifth family added to `DEFAULT_FAMILIES` server-side would otherwise open a dialog with an empty body and no clue why.
- [x] `MCP_SETTINGS_HREF` survives only for surfaces rendered **without** an agent, where there is no agent to keep you next to.

**Tests:**

- [x] `source-patch.test.ts` — a webhook block against an already-minted row is written with both ids and COMPILES into a `webhookBlocks` entry the dispatcher can find; an empty `webhookId` still throws `UNSUPPORTED_TRIGGER`.
- [x] `AgentSourcesSection.test.tsx` — webhook is a real, enabled option; it submits as `{kind:'webhook'}` rather than as a trigger; a webhook row hands its id and URL to the setup panel.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/source-patch.test.ts`