Files
paliad/docs/design-paliadin-inline-2026-05-08.md
m 142edca401 docs(paliadin): t-paliad-161 inventor design — inline modal + agent-suggested write path
Two intertwined Paliadin upgrades, scoped together because the chat
surface is where the write path is triggered and the write path is what
makes the chat non-trivial:

1. Inline slide-out modal reachable from every authenticated paliad
   page, with structured page-context payload (route_name +
   primary_entity + selection text) and per-route starter prompts.
2. Agent-suggested write path that drafts deadlines/appointments/notes
   into the existing pending_create lifecycle (t-paliad-160) with new
   provenance columns on approval_requests (requester_kind + agent_turn_id);
   approved-from-agent rows render alongside 👀 with a sparkle .

Hard call: keep the existing tmux relay for v1; recommend (but do not
commit) the Anthropic API cutover as a prerequisite for opening beyond
owner-only. Single Paliadin persona — no scope-bouncer pre-design.

Inventor parked. DESIGN READY FOR REVIEW. Awaiting m's go/no-go before
any coder shift.

Refs: m/paliad#20, t-paliad-146, t-paliad-160, t-paliad-138.
2026-05-08 19:35:39 +02:00

44 KiB
Raw Permalink Blame History

Inline Paliadin chat modal + agent-suggested-with-approval write path

Inventor: dirac · Task: t-paliad-161 · Issue: m/paliad#20 Date: 2026-05-08 · Branch: mai/dirac/inventor-inline-paliadin Status: READY FOR REVIEW — awaiting m's go/no-go before any coder shift.


0 · TL;DR

Two intertwined upgrades, scoped together because the chat surface is where the write path is triggered and the write path is what makes the chat non-trivial:

  1. Inline modal: a slide-out chat widget reachable from every authenticated paliad page, replacing the standalone /paliadin route's primacy (the page survives as the dedicated full-screen surface). The widget is context-aware — it knows which route the user is on, the primary entity in view, and any selected text — and uses that to pre-populate page-specific starter prompts.

  2. Agent-suggested write path: Paliadin gains a suggestion verb that drafts a deadline / appointment / note / project edit straight into the existing pending_create lifecycle from t-paliad-160. The user reviews via the same eye-pill 👀 surface (/inbox, list/agenda views) and approves or rejects. Approved-from-suggestion rows pick up a sparkle provenance glyph that lives next to 👀, not in place of it.

Hard call: the inline modal should keep the existing tmux-relay backend for v1. Cutover to the Anthropic Messages API is a separate substantial piece of work (auth, prompt-caching, tool framework, budget management); coupling it to the inline-modal ship would extend the design window past where m needs the modal to land. The design recommends the API cutover as a prerequisite for opening Paliadin beyond owner-only — but the inline modal at owner-only scope works fine on the existing relay.

Key locked positions (all reversible by m before coder shift):

# Decision Position
1 Modal trigger Floating button bottom-right + Cmd/Ctrl-K shortcut
2 Surface shape Right slide-out drawer, 420px desktop, full-screen on mobile
3 Visibility Every authenticated page except /paliadin, /login, /onboarding
4 Gate Same PaliadinOwnerEmail gate as today (no scope expansion in this task)
5 Backend transport Tmux relay (existing). Anthropic-API cutover deferred.
6 Multi-turn coherence Tmux session reuse already handles it; no client-side history hydrate beyond what's there
7 Context payload route_name + primary_entity_type + primary_entity_id + user_selection_text (optional) + page metadata
8 Starter-prompt library Per-route paliadinStarters registry, ships with 8 routes + a generic fallback
9 Agent-suggested attribution New columns on paliad.approval_requests (requester_kind, agent_turn_id); not on entity rows
10 Visual language glyph alongside 👀 on pending rows; persistent on approved-from-agent rows in audit log
11 Persona separation Single Paliadin SKILL.md unchanged. No pre-design for split personas.
12 Concurrency One in-flight turn per user enforced server-side (existing turnMu); request-side cancel via context

1 · Premises verified live

Read the live system before designing on top — every claim below was checked against the running paliad.de + DB on 2026-05-08, not against CLAUDE.md or memory.

  • paliad.de: live; root 200, /paliadin 302 (login redirect for anon). Production runs RemotePaliadinService against mRiver (CLAUDE.md flags tmux + claude as missing in the Dokploy container — confirmed the prod path actually goes through paliadin-shim over SSH).
  • Migration tracker: paliad.paliad_schema_migrations.version=69. Next free migration is 070.
  • paliad.approval_requests existing columns: id, project_id, entity_type, entity_id, lifecycle_event, pre_image, payload, requested_by, requested_at, required_role, status, decided_by, decided_at, decision_kind, decision_note, created_at, updated_at. No agent_* columns yet — migration 070 adds them.
  • paliad.paliadin_turns: already has a page_origin TEXT column populated from req.PageOrigin on every turn. Today the frontend only ever sets window.location.pathname on the standalone page; the inline widget will widen this from a single string into a structured payload.
  • paliad.deadlines + paliad.appointments: already carry approval_status text NOT NULL DEFAULT 'approved' + pending_request_id uuid from migration 054. The 👀 eye-pill renders on pending rows in events.ts:521 and agenda.ts:289 via .approval-pill--icon.
  • Sidebar (frontend/src/components/Sidebar.tsx:123): already has a /paliadin entry hidden by default, revealed by client/sidebar.ts after /api/me confirms the caller is the Paliadin owner. The same reveal hook drives the inline modal's visibility.
  • PaliadinOwnerEmail (internal/services/paliadin.go:51): matthias.siebels@hoganlovells.com. Hard-coded gate. No scope expansion in this task.
  • youpc.org reference files all readable at /home/m/dev/web/youpc.org/: frontend/templates/ai/sidebar-widget.html, frontend/js/utils/ai-chat-client.js, frontend/js/components/ai/sidebar.js, youpc-go/internal/services/youpc_ai_relay.go, scripts/youpc-ai-shim. Klaus's brief in #20 maps to these directly.

One CLAUDE.md correction: the project's CLAUDE.md currently calls ANTHROPIC_API_KEY "reserved-but-unused for the eventual production-v1 Paliadin". That language stays correct — this design recommends but does not commit the API cutover. No CLAUDE.md edit in the implementation PR.


2 · Why the inline modal matters

m's framing (#20 §1) is "Paliadin should be reachable from anywhere". The real differentiation argument is sharper: the value of the assistant collapses to "open a chat tab" if you can't get to it without leaving the page you're already working on. For a patent-practice tool, the most common questions are page-anchored:

  • On /projects/<id> → "Was steht für diese Akte diese Woche an?"
  • On /deadlines/<id> → "Erkläre mir die Klageerwiderungsfrist nach UPC RoP 23.1."
  • On /agenda with selection → "Schreibe einen Nachtrag zu diesem Termin: …"

The standalone /paliadin page solves none of these because asking the question requires the user to (a) leave the page, (b) re-explain context the page already had, (c) navigate back. The inline modal solves (a) by construction; (b) is solved by the context payload (§4); (c) is moot.

The widget is therefore the default surface going forward; the /paliadin standalone page survives as the dedicated full-screen mode (useful for long sessions where the slide-out is too narrow). Both speak the same backend.


3 · Modal — shape, trigger, injection

3.1 Visual shape (recommendation)

Right-edge slide-out drawer — same pattern as youpc.org's ai-sidebar-widget.html because it solves the right problems:

  • Doesn't crowd the page content (drawer slides in over a translucent scrim, page underneath stays visible at ~70% opacity so the user can reference what they were looking at).
  • Mobile-responsive for free: at <640px the drawer goes full-screen and the floating button hides while open.
  • Doesn't fight with paliad's existing left sidebar (Sidebar.tsx) — the drawer claims the right edge, the sidebar keeps the left.

Considered and rejected:

  • Always-visible secondary sidebar (left or right rail). Wastes ~280px of horizontal real-estate on every page; collides with the sidebar on mobile.
  • Popover anchored to the floating button. Too small for multi-turn conversations; mobile would need a separate full-screen mode anyway.
  • Fullscreen takeover overlay. Defeats the purpose — if it covers the page you can't reference what you were looking at.

3.2 Trigger

Two entry points:

  1. Floating action button at bottom-right (position: fixed; bottom: 20px; right: 20px;). Lime accent (var(--color-accent)), glyph. Same auth-reveal hook as the sidebar /paliadin link — display:none until client/sidebar.ts confirms /api/me.email === PaliadinOwnerEmail.

  2. Keyboard shortcut: Cmd-K (macOS) / Ctrl-K (other). Standard command-palette muscle memory. Doesn't collide with browser shortcuts. Paliad has no other Cmd-K binding today (verified via grep on keydown handlers).

The shortcut also dismisses the drawer when it's open. Esc dismisses unconditionally.

3.3 Drawer content

Layout (top to bottom):

┌──────────────────────────────┬─┐
│ ✨ Paliadin             ↻ ↗ ✕│ │ Header: name, reset-session, open-fullscreen, close
├──────────────────────────────┼─┤
│ [Auf dieser Seite]            │
│ Akte: Acme v. Müller          │ │ Context chip — collapsible, shows what Paliadin
│ 19 Fristen · 4 Termine        │ │ knows about the current page (read from payload)
├──────────────────────────────┼─┤
│ [empty-state starter prompts] │
│   • "Was steht hier an?"      │
│   • "Erkläre die offene…"     │
│   • "Lege eine Frist an"      │
├──────────────────────────────┼─┤
│ <messages>                    │ │ Scrollable, user-right / paliadin-left
│  > User bubble                │
│   < Paliadin bubble + ✨ chip │ │ ✨ chip = "I drafted this — it's awaiting your approval"
├──────────────────────────────┼─┤
│ [textarea + send + abort]    │
└──────────────────────────────┴─┘

The button is the escape hatch to the standalone /paliadin for users who want a full-screen session with full message history visible.

3.4 Injection mechanism

One file edits the universe: frontend/src/components/PaliadinWidget.tsx emits an inline <div id="paliadin-widget" style="display:none">…</div> that page-template files include alongside <PWAHead /> and <Sidebar />.

The mechanical edit pass: every authenticated TSX page (~30 files) gets a <PaliadinWidget /> near </body>. This mirrors the existing <PWAHead /> mechanical pass from t-paliad-042 and is the cleanest way to guarantee the widget reaches every page without HTMX or runtime injection.

Alternative considered: server-side template fragment injected by Go's HTML response writer (cleaner: no per-page edit). Rejected because paliad uses bun-built static HTML files, not templated server responses — there's no place to inject server-side. The mechanical pass is fine; the boilerplate it adds is one component.

Visibility predicate (in client/paliadin-widget.ts):

  • Hide on /paliadin (the standalone page IS Paliadin, the widget would be redundant).
  • Hide on /login, /onboarding (no auth context).
  • Hide until /api/me resolves to email === PaliadinOwnerEmail. Same fail-closed pattern as the sidebar link.
  • Show on every other authenticated page.

3.5 What about the BottomNav (mobile)?

BottomNav.tsx has 5 slots (Dashboard / Projects / Add / Agenda / Menu) — full. Adding a Paliadin slot would require evicting one. Don't. The floating button is fine on mobile (it sits in the bottom-right corner above the bottom nav, with z-index arbitration). At full-screen-drawer size on mobile, the floating button hides while the drawer is open.


4 · Context payload — what flows from frontend to backend

4.1 Schema

The current TurnRequest.PageOrigin is a single string (the URL path). The inline modal needs more. Define a structured payload:

interface PaliadinContext {
  // Stable route key — independent of URL params. e.g. "projects.detail"
  // not "/projects/61e3.../tab=team". The frontend computes this from
  // `window.location.pathname` via a route-table lookup.
  route_name: string;

  // Path including query string (cosmetic; for audit + display only).
  page_origin: string;

  // The "primary entity" of the current page, if any. Examples:
  //   /projects/<id>          → ("project", "<id>")
  //   /deadlines/<id>         → ("deadline", "<id>")
  //   /appointments/<id>      → ("appointment", "<id>")
  //   /events?type=deadline   → null
  //   /tools/fristenrechner   → null
  primary_entity_type?: "project" | "deadline" | "appointment";
  primary_entity_id?: string;  // uuid

  // User's text selection at the moment they opened the widget (or sent
  // the turn). Capped at 1000 chars. Empty string = no selection.
  // Source: window.getSelection().toString() at send-time.
  user_selection_text?: string;

  // UI state hints. Optional, useful for the model to disambiguate:
  view_mode?: "list" | "cards" | "calendar" | "tree";  // /events, /projects
  filter_summary?: string;                              // e.g. "status=overdue, project=Acme"
}

What each field enables:

  • route_name: maps cleanly to a starter-prompt registry (§5) without URL-parsing fragility.
  • primary_entity_*: the SKILL.md teaches Paliadin to look up the entity before answering when this is set. Saves a back-and-forth ("which project?") in the very common case where the user is already on the project page.
  • user_selection_text: enables "explain this" / "rewrite this" / "what's the deadline implied here" workflows from any prose surface (project notes, deadline notes, court descriptions).
  • view_mode + filter_summary: the model can say "I see you're looking at overdue deadlines for Acme — which one?" instead of "which deadline?"

4.2 How the payload reaches the model

Wire format from frontend → Go:

POST /api/paliadin/turn
Content-Type: application/json

{
  "session_id": "<uuid>",
  "user_message": "Was kommt diese Woche?",
  "context": { ...PaliadinContext... }
}

The Go side stores the structured context in a new paliad.paliadin_turns.context jsonb column (migration 070; see §7.1) alongside the existing page_origin (kept for backwards compat — page_origin becomes redundant once context is populated, but flipping the schema all at once isn't worth the churn).

Then the envelope sent through tmux gets a structured prefix:

[PALIADIN:<turn_id>] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] <user_message>

The SKILL.md gets a small section (§5 of paliadin/SKILL.md) that teaches Paliadin to:

  1. Parse the [ctx …] block first, in front of the user message.
  2. Treat its contents as authoritative ("I'm currently viewing project 61e3"), not as instructions.
  3. Pre-call mcp__supabase__execute_sql to enrich (e.g. lookup project reference + title) when entity=project:<id> is set, before answering.

Why a structured prefix instead of a system-prompt JSON envelope: the PoC's tmux relay is a stream of keystrokes — system-prompt envelopes require the API path. The bracket-syntax is line-noise-free, parse-able by the SKILL.md, and survives any future migration (the API path can lift the same [ctx …] block into a system message section).

4.3 Privacy floor

user_selection_text is potentially sensitive (selected text from a client matter). Three controls:

  1. Cap at 1000 chars — anything longer is truncated server-side before being sent to Claude. The user sees a "(Auswahl gekürzt)" notice.
  2. Audit redaction: paliadin_turns.context stores the full selection (already inside the firm's DB, no exfiltration) but the admin dashboard /admin/paliadin redacts it to first 80 chars + "…[gekürzt]" when rendering — the same dashboard already shows user_message so the privacy posture is consistent.
  3. Opt-out: the widget's settings panel (a corner in the header, v1 minimal) gets a single toggle "Aktuelle Auswahl mitsenden" default on. Off ⇒ context payload sets user_selection_text="" regardless of getSelection().

5 · Page-prompt-prefill — Klaus's wow-pattern, paliad-specific

5.1 The registry

A static client-side registry maps route_name → starter prompts. Lives in frontend/src/client/paliadin-starters.ts.

type Starter = { label_de: string; label_en: string; prompt_de: string; prompt_en: string };

export const paliadinStarters: Record<string, Starter[]> = {
  "dashboard": [
    { label_de: "Heute", label_en: "Today",
      prompt_de: "Was steht heute an?", prompt_en: "What's on my plate today?" },
    { label_de: "Diese Woche", label_en: "This week",
      prompt_de: "Welche Fristen sind diese Woche?", prompt_en: "Which deadlines are this week?" },
    { label_de: "Nächste Schritte", label_en: "Next steps",
      prompt_de: "Was sollte ich als nächstes erledigen?", prompt_en: "What should I tackle next?" },
  ],
  "projects.detail": [
    { label_de: "Status der Akte", label_en: "Project status",
      prompt_de: "Was ist der aktuelle Status dieser Akte?", prompt_en: "What's the status of this project?" },
    { label_de: "Diese Woche", label_en: "This week",
      prompt_de: "Was steht für diese Akte diese Woche an?", prompt_en: "What's on for this project this week?" },
    { label_de: "Frist anlegen", label_en: "Add a deadline",
      prompt_de: "Lege eine Frist für diese Akte an: ", prompt_en: "Add a deadline for this project: " },
  ],
  "deadlines.detail": [
    { label_de: "Erkläre die Frist", label_en: "Explain this deadline",
      prompt_de: "Erkläre mir die Frist auf dieser Seite.", prompt_en: "Explain this deadline." },
    { label_de: "Rechtsgrundlage", label_en: "Legal basis",
      prompt_de: "Welche Norm ist hier einschlägig?", prompt_en: "What's the relevant rule?" },
  ],
  "agenda": [ /* … */ ],
  "events": [ /* … */ ],
  "inbox": [ /* … */ ],
  "tools.fristenrechner": [ /* … */ ],
  "glossary": [ /* … */ ],
  // Generic fallback for unmapped routes.
  "_default": [
    { label_de: "Was kann ich für dich tun?", label_en: "What can I help with?",
      prompt_de: "", prompt_en: "" },
  ],
};

The widget's empty state renders the matching starter list. Click → the prompt populates the textarea (or sends immediately if prompt_de is empty — letting the user type their own). Picking up "Lege eine Frist an: " seeds the input partially so the user finishes the sentence — a deliberate friction-reducer for the common "draft and approve" workflow.

5.2 Why per-route registry, not LLM-generated suggestions?

Considered: dynamically ask Paliadin to suggest 3 starters based on context. Rejected because:

  1. Latency: every drawer-open would burn a full turn before the user even types. The PoC's tmux turn is ~2-5 seconds cold; that's an unusable empty state.
  2. Determinism: m's audience (PA team) needs predictable affordances. "What does this thing know how to do?" answered the same way each visit beats "what does this thing know how to do today?"
  3. Translatability: hand-crafted bilingual starters live next to the rest of the i18n. LLM-generated would be one language at a time.

The registry is small (~10 routes × 3 starters × 2 langs = ~60 strings) and lives next to i18n.ts patterns m's team already understands.


6 · Backend transport — tmux relay vs Anthropic API

6.1 Recommendation: keep tmux relay for v1 of the inline modal

Two reasons:

  1. Scope discipline: the inline modal's user-visible payoff is independent of which backend serves it. Cutover to the API is a 4-6 commit piece of substantial work (auth headers, prompt-cache management, tool-definition framework, streaming format conversion, budget controls, audit reshape, plus the existing tmux path needs to remain as fallback during rollout). Bundling it with the inline modal doubles the design's blast radius for no inline-modal-side benefit.
  2. Owner-only scope: paliad's user base today is PaliadinOwnerEmail = m. One user. The tmux relay's serialised one-turn-at-a-time, ~2-5s cold start, ~1-3s warm response holds up fine for one user clicking through the day.

6.2 What the API cutover would fix (recommend as Phase 2)

When scope expands beyond owner-only — even just to "m + 2 PA colleagues for piloting" — the tmux relay starts to bend:

  • Concurrency: serialised turn lock means PA-A waits while PA-B thinks. Per-user tmux sessions help but mRiver still has finite resources.
  • Latency: ~2s cold tmux start is ok for one user; bad for "I just opened the widget, ask a quick question, close" rhythm at scale.
  • Cost vs subscription: m's Claude Code subscription covers his personal turns. Multi-user would either need m's account to absorb the load (dubious) or the firm's enterprise key (the actual prod path).
  • Streaming: tmux streaming today is the youpc.org-style "tail the response file as it grows" stopgap. Real token streaming (TTFB <1s) needs the API.

The API cutover should therefore be a prerequisite for opening Paliadin beyond owner-only. The inline modal's design assumes API-cutover-ready boundaries (the relay interface in §6.4) so when m flips the switch, the inline-modal frontend doesn't change.

6.3 Why not cutover now anyway?

It's tempting because:

  • The CLAUDE.md note about ANTHROPIC_API_KEY reserved-but-unused has been there since 2026-04-16 and would benefit from being un-deferred.
  • The inline modal is the natural moment to revisit infrastructure.
  • Klaus's youpc.org has built a relay-interface abstraction (youpcAIRelay interface in youpc_ai_relay.go) that paliad could borrow for the swap point.

Counter-arguments that win:

  • Today's tmux relay shipped only 2-3 days ago (paliadin_remote.go reference t-paliad-151). It's not a legacy substrate to escape — it's fresh code that hasn't earned a rewrite yet.
  • The compliance question for the API path (HLC-key vs personal-key, audit retention requirements, prompt-logging policy) hasn't been resolved with HLC IT. m flagged this as the biggest open question in the t-paliad-146 design and it's still open.
  • Inline modal can ship entirely on the existing relay; if the API cutover comes later, the modal doesn't have to re-ship.

Therefore: design a small interface seam (§6.4) so v1 doesn't paint us into a tmux-only corner, but don't pay the cutover cost in this PR.

Mirror youpc.org's pattern (youpc_ai_relay.go) but smaller — paliad has one role, no streaming variant yet:

// internal/services/paliadin_relay.go (new)
type PaliadinRelay interface {
  RunTurn(ctx context.Context, session string, turnID uuid.UUID,
          envelope string) ([]byte, error)
  Reset(ctx context.Context, session string) error
  HealthGate(ctx context.Context, session string) error
}

LocalPaliadinService and RemotePaliadinService keep their current shapes; the audit-row writes (paliadinDB) stay shared. RunTurn becomes a thin wrapper that builds the envelope (with the new [ctx …] block from §4.2) and delegates to the relay. A future httpAPIRelay slots in beside the SSH one without touching the audit/turn-row code.

Don't extract the interface unless the inline modal's PR organically needs it. If the modal can ship without restructuring the existing relay, the abstraction-cost is negative.


7 · Agent-suggested write path — schema + flow

7.1 Schema decision: extend approval_requests, not entity rows

The brief listed three candidate locations:

Option Where the marker lives Verdict
A boolean agent_suggested on paliad.deadlines / paliad.appointments Reject: pollutes domain tables; survives past approval (the entity is no longer "agent-suggested" once it's been live for six months); doesn't carry which agent / which turn
B text suggested_by_agent on entity rows (multi-agent provenance) Same problems as A; "agent name" never used because we have one agent
C New columns on paliad.approval_requests linking back to the suggesting turn Recommended

The approval_request row IS the audit-chain entry; the entity row is just current state. Provenance information belongs on the audit-chain row where it can persist forever without polluting the entity schema.

Migration 070 (proposed):

ALTER TABLE paliad.approval_requests
  -- 'user' = direct user create; 'agent' = drafted by Paliadin from a chat turn.
  ADD COLUMN requester_kind text NOT NULL DEFAULT 'user'
    CHECK (requester_kind IN ('user', 'agent')),
  -- When requester_kind='agent', the chat turn the suggestion came from.
  -- NULL otherwise. ON DELETE SET NULL — the audit record survives even
  -- if the turn row is purged (paliadin_turns has no retention policy
  -- today, but design for it).
  ADD COLUMN agent_turn_id uuid
    REFERENCES paliad.paliadin_turns(turn_id) ON DELETE SET NULL,
  ADD CONSTRAINT approval_requests_agent_xor
    CHECK (
      (requester_kind = 'agent' AND agent_turn_id IS NOT NULL)
      OR (requester_kind = 'user' AND agent_turn_id IS NULL)
    );

CREATE INDEX approval_requests_agent_turn_idx
  ON paliad.approval_requests (agent_turn_id)
  WHERE agent_turn_id IS NOT NULL;

-- paliadin_turns also gets the structured context column.
ALTER TABLE paliad.paliadin_turns
  ADD COLUMN context jsonb;

requested_by continues to be the user uuid — even for agent suggestions the user is the initiator (Paliadin acts on their behalf, never autonomously). requester_kind distinguishes "the user typed Speichern" from "the user typed /lege eine Frist an: … to Paliadin and Paliadin drafted it; the user has not yet approved".

7.2 The flow

  1. User asks Paliadin: "Lege eine Frist für diese Akte an: 16.05. Klageerwiderung Acme".

  2. Paliadin's SKILL.md gets a new section: "Agent-suggested writes" that teaches it to call a new MCP tool paliad__suggest_deadline (and siblings for appointment / project_note / project_attach). The tool's server-side handler:

    • Validates the user has visibility on the project (existing can_see_project).
    • Calls DeadlineService.Create with the new IsAgentSuggestion=true flag and agent_turn_id=<current turn>.
    • Inside the create-tx, after the entity insert, the existing approval hookup runs: ApprovalService.SubmitCreate(...). Critical change: when IsAgentSuggestion is set, the submit unconditionally creates an approval request even if no policy applies — the agent path is approval-gated by construction, not by partner-unit policy.
  3. Eye-pill 👀 + sparkle render on the resulting row in /inbox, /deadlines, /agenda. Click → standard approve/reject UI. Approve flips status to approved, sets decision_kind='peer' (or admin_override if global_admin), the entity becomes live.

  4. Audit chain on the project's Verlauf:

    • deadline_approval_requested event with metadata.requester_kind='agent' + metadata.agent_turn_id=<uuid>. Verlauf renderer picks this up and labels the event "Paliadin hat eine Frist vorgeschlagen ".
    • deadline_approval_approved with the user as decided_by + the existing decision_kind ladder. Verlauf renders "Anna hat Paliadin's Vorschlag genehmigt ".

7.3 Why agent-suggested unconditionally goes through approval

Two reasons:

  1. Trust gradient: even if a partner has direct create authority on their own projects (no policy = no approval needed today), an agent suggesting on their behalf is qualitatively different. Visible review keeps the user in the loop.
  2. Single audit shape: today the partner-unit policy decides which creates need approval; bypassing that for agent suggestions creates a second code path. Forcing agent suggestions into the approval pipeline means there's exactly one "agent created an entity" audit shape (the approval_request row).

A user who finds the per-suggestion review tedious can request /genehmige einfach alles was Paliadin vorschlägt — but that's a Phase 2 setting ("auto-approve agent suggestions on projects where I'm lead"), explicitly out-of-scope for v1 (and m says so in #20: "Multi-turn agent loops … Every creation gets the user's eye.").

7.4 What entities can Paliadin suggest in v1?

The brief mentions "deadlines, appointments, notes, project-tree edits". Recommend ordering by reversibility + audit complexity:

Entity v1? Why
Deadline create Yes Highest-value (Klaus would rate this top), well-supported by existing pending_create lifecycle
Appointment create Yes Same lifecycle substrate; symmetric tool
Project note (project_events.note) Yes Read-only audit event, no approval gate today — but for agent-authored notes route through approval anyway (consistency)
Project-tree edit (move, rename) No, defer Approval lifecycle for project moves doesn't exist; designing it is its own task.
Deadline / appointment edit No, defer Edits today only need approval when date-fields change (t-paliad-138 §Q4). Agent edits would need their own design pass for "what changes does the user see in the diff?"
Deadline complete No, defer Same reason — complete already has approval lifecycle, but the agent path is qualitatively different (a deadline being marked done is high-stakes; design it after a v1 lands and we see how often agent-creates need editing)

v1 = create only. Edits/completes are a Phase 2 expansion.


8 · Visual language — alongside 👀, not in place of

8.1 Design

.approval-pill--agent is a new modifier that sits next to the existing .approval-pill--icon (the 👀 glyph), not replacing it.

Row state Pill rendering
approval_status='pending' AND requester_kind='user' 👀
approval_status='pending' AND requester_kind='agent' 👀
approval_status='approved' AND requester_kind='user' (no pill)
approval_status='approved' AND requester_kind='agent' (subtle, in the row's secondary badge slot — not a pill)

The 👀 + pairing communicates: "this is awaiting approval and came from Paliadin". Hover (title attr) on reads: "Paliadin hat das vorgeschlagen — angeklickt klärt".

Why both glyphs, not a fused single glyph? The two questions ("is this awaiting approval?" / "did a human or Paliadin originate this?") are orthogonal — a future autopilot mode might let some agent suggestions auto-approve, in which case 👀 disappears but stays. Keeping them separate keeps the visual taxonomy decomposable.

8.2 Where renders

Three surfaces:

  1. Eye-pill row (/inbox, /deadlines, /agenda, project detail, /events): 👀 side-by-side when applicable. Same .approval-pill shape, separate elements.
  2. Audit log (/admin/audit-log + project Verlauf): the row's "approved by" line gets a trailing when the underlying request had requester_kind='agent'. Reads "Anna Schmidt" → tooltip "Über Paliadin vorgeschlagen, von Anna genehmigt".
  3. Approval request inbox card: the requester's name in the inbox card gets a subtle " Paliadin (für Anna)" badge instead of just "Anna" when requester_kind='agent'.

8.3 The "+p" annotation question

m's #20 said: "we say USER + p or with a star or something". The "+p" text annotation reads in audit logs but doesn't scan in a pill row ( is recognisable; "+p" is not without learning). Recommend: as the universal glyph. Reserve a textual fallback for compliance-export contexts where emojis don't render — there the audit string becomes "Anna [agent: Paliadin]" rather than "Anna ".


9 · Persona separation

m's brief asked whether to lean on klaus's "scope-bouncer in SKILL.md" pattern (Hugo refuses legal questions, points at Lexie; Lexie refuses "how do I subscribe?", points at Hugo) for paliad — i.e. pre-design multi-persona infrastructure.

Recommendation: don't. Paliad has one Paliadin (Patentpraxis assistant at HLC's Patent team). The youpc.org split exists because youpc.org has fundamentally different audiences — public visitors (Hugo handles "how does this site work?") and premium-beta lawyers (Lexie does case-law research). Their refusal scopes are different because their users are different.

Paliad's audience is one cohesive group: HLC PA team. They want one assistant that does "everything PA-relevant" — Aktenmanagement, Fristen, Begriffe, Gerichte, UPC-Recht. There's no audience pair that requires distinct refusal scopes.

If Phase 2 wants to add a case-law research persona (e.g. cross-link to youpc.org's Lexie) — that's a separate skill alongside Paliadin, not a persona-split inside Paliadin. The infrastructure for that already exists in Claude Code's skill router (multiple skills, each its own description/persona).

No SKILL.md changes for persona separation in this design. The skill gets §4.2's [ctx …] parser added, plus §7.2's paliad__suggest_* tool guidance, but the persona stays "der Paliad-Patentpraxis-Assistent".


10 · Phasing & implementation surface

10.1 Suggested phasing (single PR is feasible; split optional)

Slice A — schema + relay seam (~1 commit)

  • Migration 070: approval_requests.requester_kind + agent_turn_id + xor-check + index; paliadin_turns.context jsonb.
  • Optional PaliadinRelay interface extraction (skip if it makes the PR bigger without removing duplication).

Slice B — context payload + SKILL.md update (~1 commit)

  • Wire structured PaliadinContext from frontend → Go → tmux envelope.
  • SKILL.md [ctx …] parsing + behaviour.
  • client/paliadin-context.ts route-table + entity extraction (one file).
  • /api/paliadin/turn accepts the new body shape (backwards-compatible: old page_origin still honoured if context is absent).

Slice C — inline widget (~1 commit, biggest)

  • frontend/src/components/PaliadinWidget.tsx.
  • client/paliadin-widget.ts (drawer state, sending, history, hide-on-route).
  • client/paliadin-starters.ts registry (8 routes + default).
  • Mechanical pass: every authenticated TSX adds <PaliadinWidget />.
  • CSS: .paliadin-widget, .paliadin-drawer, .paliadin-trigger, .paliadin-context-chip, ~150 lines of global.css.
  • ~30 i18n keys.

Slice D — agent-suggested write path (~1 commit)

  • paliad__suggest_deadline + paliad__suggest_appointment MCP tools (or HTTP tool, depending on how the MCP scope already wires — internal/handlers/paliadin_tools.go if new file warranted).
  • DeadlineService.Create / AppointmentService.Create accept a IsAgentSuggestion bool + AgentTurnID *uuid.UUID plumbed into ApprovalService.SubmitCreate (which gets a sibling SubmitAgentCreate that always creates a request even without policy).
  • SKILL.md adds the §7.2 "Agent-suggested writes" instruction block.

Slice E — visual language (~1 commit)

  • .approval-pill--agent CSS.
  • events.ts, agenda.ts, inbox.ts render when requester_kind='agent'.
  • Audit-log + Verlauf renderer extends to surface on approved-from-agent events.
  • ~10 i18n keys for the badges + tooltips.

Recommended PR shape: single PR with five commits in this order. Slice A's migration is independent (can deploy without the rest); Slice D needs B + C; Slice E builds on D. If sliced into multiple PRs, A and B-C can ship independently of D-E (modal works as read-only chat without the write path; that's already an upgrade).

10.2 Files of note for the implementer

New files:

  • internal/db/migrations/070_paliadin_inline.{up,down}.sql
  • internal/handlers/paliadin_tools.go (suggest verbs)
  • internal/services/paliadin_relay.go (optional interface)
  • frontend/src/components/PaliadinWidget.tsx
  • frontend/src/client/paliadin-widget.ts
  • frontend/src/client/paliadin-starters.ts
  • frontend/src/client/paliadin-context.ts

Edits:

  • internal/services/paliadin.go (TurnRequest gains structured Context; insertTurnRow stores it)
  • internal/services/approval_service.go (SubmitCreate accepts agent-flag; SubmitAgentCreate variant)
  • internal/services/deadline_service.go, internal/services/appointment_service.go (Create accepts IsAgentSuggestion + AgentTurnID; threads to ApprovalService)
  • internal/handlers/paliadin.go (turnRequest body schema)
  • frontend/src/client/events.ts, agenda.ts, inbox.ts ( render)
  • frontend/src/styles/global.css (drawer + pill CSS)
  • frontend/src/client/i18n.ts (~40 new keys × 2 langs)
  • frontend/src/components/Sidebar.tsx — no edit (the existing sidebar link logic already gates on owner; no new entries)
  • ~30 page TSX files: mechanical <PaliadinWidget /> add (~1 line each)
  • ~/.claude/skills/paliadin/SKILL.md (via scripts/install-paliadin-skill): add §4.2 ctx-parser block + §7.2 suggest-tools block

Total estimated surface: comparable to t-paliad-146 (the original Paliadin design — ~3500-4500 LoC) plus the agent-suggest write path (~1000 LoC). Single PR is feasible if the implementer is pattern-fluent; split is fine.


11 · Open questions for m

These are the calls m has to make before any coder shift starts.

Q1 — Scope gate: still owner-only?

The inline modal's design assumes PaliadinOwnerEmail stays as the only gate (m only). When does scope expand?

  • (a) Stays owner-only for v1 of inline modal — recommended; matches brief. ← inventor's pick
  • (b) Extend to a beta-features whitelist (firm-wide email domain + flag).
  • (c) Expand to all of hoganlovells.com immediately. Requires API cutover (Phase 2 prerequisite).

Q2 — Backend: tmux relay or Anthropic API for the inline modal?

  • (a) Keep tmux relay for v1 — recommended; ships fastest. ← inventor's pick
  • (b) Cutover to Anthropic API now — slower ship; better long-term.
  • (c) Both: ship tmux v1, design the API path as a parallel deferred PR.

Q3 — Agent-suggested entities in v1: where to draw the line?

  • (a) Create-only: deadline, appointment, note. Defer edits/completes/project-tree. ← inventor's pick
  • (b) Create + edit (deadline + appointment).
  • (c) Create + edit + complete + project-tree.

Q4 — Visual language for agent provenance?

  • (a) glyph alongside 👀 — recommended; orthogonal to lifecycle. ← inventor's pick
  • (b) "+p" text annotation in audit lines only; no glyph in pills.
  • (c) Replace 👀 with for agent-pending rows (single glyph, more compact).

Q5 — Selection text in context payload — default on or off?

  • (a) Default on, opt-out via widget settings — recommended. ← inventor's pick
  • (b) Default off, opt-in via widget settings.
  • (c) Always on, no toggle.

Q6 — Widget visibility scope: everywhere except /paliadin, or finer?

  • (a) Everywhere except /paliadin, /login, /onboarding — recommended; lowest cognitive load. ← inventor's pick
  • (b) Only on data-bearing pages (dashboard, projects, deadlines, agenda, events, inbox); hide on tool pages (fristenrechner etc.).
  • (c) User-configurable per page.

Q7 — Modal vs dialog: drawer + scrim, or non-modal floating panel?

  • (a) Modal slide-out drawer with scrim (focus-traps) — recommended. ← inventor's pick
  • (b) Non-modal floating panel (page stays interactive while widget is open).

Q8 — Keyboard shortcut for opening: Cmd-K?

  • (a) Cmd-K / Ctrl-K — recommended. ← inventor's pick
  • (b) Different shortcut (m to specify).
  • (c) No shortcut, button-only.

Q9 — Context payload truncation cap (selection text)?

  • (a) 1000 chars — recommended; balances usefulness vs prompt-bloat. ← inventor's pick
  • (b) Higher cap (5000 chars).
  • (c) Lower cap (300 chars).

Q10 — Persona separation pre-design?

  • (a) Single Paliadin, no scope-bouncer pattern — recommended; YAGNI. ← inventor's pick
  • (b) Add scope-bouncer pattern now (Paliadin refuses non-paliad questions, points at... where?).
  • (c) Pre-design split with a second skill (Phase 2 case-law researcher).

Q11 — Auto-approve some agent suggestions?

  • (a) No, every agent suggestion needs the user's eye — recommended; matches m's #20 verbatim. ← inventor's pick
  • (b) Auto-approve agent suggestions on projects where the user is lead.
  • (c) Auto-approve when the suggestion was a direct response to "Lege … an" (user opted in by phrasing).

Same substrate as t-paliad-146 + t-paliad-160 + t-paliad-138 (paliadin, approval pipeline, eye-pill UI). Pattern-fluent Sonnet work.

  • (a) Any pattern-fluent Sonnet coder — recommended. ← inventor's pick
  • (b) The same coder who shipped t-paliad-160 (deepest context on the approval pipeline).
  • (c) Two coders: one on Slices A-C (modal + context), one on Slices D-E (agent-suggest + visual language).

12 · Out of scope (for now) — preserved

Per m's brief:

  • Direct Paliadin write permission (no RLS bypass, no agent service-role identity). The approval gate stays the only path agents take into prod data.
  • Multi-turn agent loops — no chained writes without per-step user approval.
  • Production-v1 Anthropic API cutover for the existing standalone /paliadin route (recommended in §6 as a prerequisite for opening beyond owner-only, but not committed in this task).
  • Edits / completes / project-tree as agent-suggestible entities (§7.4 defers to Phase 2).
  • Persona separation infrastructure (§9 defers indefinitely).

13 · Trade-offs flagged

Trade-off What we accept Mitigation
Tmux-relay v1 caps concurrency at one turn per user Owner-only v1 makes this fine Spec the relay-interface seam (§6.4) so API cutover is non-disruptive
Mechanical <PaliadinWidget /> pass touches ~30 files Same pattern as t-paliad-042 PWAHead, low risk One commit per slice keeps blame surface tight
Agent suggestions unconditionally route through approval Some users may find it tedious Phase 2 auto-approve setting (m wants Q11 = no, so this isn't urgent)
Two glyphs (👀 + ) might confuse first-time approvers Slight onboarding cost Tooltip on hover; admin/onboarding doc one-liner
Selection-text in context payload risks accidental info leakage Low (data already in DB) Cap + redaction in admin dashboard (§4.3)
Per-route starter registry needs maintenance as routes evolve Yes; cost is real Default fallback ensures no route is silent; route renames are caught by build (registry imports route names as a const map)

14 · Implementation hygiene

  • No bare CSS tokens. New .paliadin-widget* + .approval-pill--agent CSS uses existing --color-* / --accent-* / --bg-soft tokens. The reminder from t-paliad-150 (third occurrence of bare-token leaks) holds.
  • No RAISE EXCEPTION in migration 070 — Maria's build constraint.
  • No 2>&1 on diagnostic — global rule.
  • i18n must compile — every new label gets a key in client/i18n.ts
    • DE/EN values; bun run build regenerates i18n-keys.ts.
  • Build + vet + test gatego build ./... + go vet ./... + go test ./... + cd frontend && bun run build all clean before push.
  • Don't self-merge — push branch, comment on Gitea #20, await m's merge gate.
  • Don't close issue #20 — m closes issues. Set done label on approval.

15 · End-of-shift checklist (this design)

  • Read m/paliad#20 + Klaus's reply (msg #1563 / comment).
  • Read existing paliadin.go + paliadin_remote.go + approval_service.go + paliadin-shim + install-paliadin-skill + ~/.claude/skills/paliadin/SKILL.md.
  • Read youpc.org reference: sidebar-widget.html + sidebar.js + ai-chat-client.js + youpc_ai_relay.go.
  • Verify live state: paliad.de up, migration tracker at 69, schema columns matched expectations, eye-pill 👀 already wired.
  • Take a position on every decision in the brief (see §0 table; §11 for the open questions).
  • No hour estimates anywhere in the doc.
  • Recommend implementer + phasing.
  • Commit this doc on mai/dirac/inventor-inline-paliadin.
  • Push branch.
  • Comment on Gitea #20 with summary + doc link.
  • File mBrian synthesis node under topic-paliadin (or equivalent).
  • mai report completed "DESIGN READY FOR REVIEW: …" and stop. Do not auto-flip to coder.

Inventor parked after this commit. The head will surface to m for the go/no-go gate before any coder shift begins. Skipping that gate has burned commits before (m/mAi#142); the gate is non-negotiable.