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.
44 KiB
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:
-
Inline modal: a slide-out chat widget reachable from every authenticated paliad page, replacing the standalone
/paliadinroute'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. -
Agent-suggested write path: Paliadin gains a suggestion verb that drafts a deadline / appointment / note / project edit straight into the existing
pending_createlifecycle 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,
/paliadin302 (login redirect for anon). Production runsRemotePaliadinServiceagainst mRiver (CLAUDE.md flagstmux + claudeas missing in the Dokploy container — confirmed the prod path actually goes throughpaliadin-shimover SSH). - Migration tracker:
paliad.paliad_schema_migrations.version=69. Next free migration is 070. paliad.approval_requestsexisting 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. Noagent_*columns yet — migration 070 adds them.paliad.paliadin_turns: already has apage_origin TEXTcolumn populated fromreq.PageOriginon every turn. Today the frontend only ever setswindow.location.pathnameon the standalone page; the inline widget will widen this from a single string into a structured payload.paliad.deadlines+paliad.appointments: already carryapproval_status text NOT NULL DEFAULT 'approved'+pending_request_id uuidfrom migration 054. The 👀 eye-pill renders on pending rows inevents.ts:521andagenda.ts:289via.approval-pill--icon.- Sidebar (
frontend/src/components/Sidebar.tsx:123): already has a/paliadinentry hidden by default, revealed byclient/sidebar.tsafter/api/meconfirms 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
/agendawith 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
<640pxthe 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:
-
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/paliadinlink —display:noneuntilclient/sidebar.tsconfirms/api/me.email === PaliadinOwnerEmail. -
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 onkeydownhandlers).
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/meresolves toemail === 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:
- Parse the
[ctx …]block first, in front of the user message. - Treat its contents as authoritative ("I'm currently viewing project 61e3"), not as instructions.
- Pre-call
mcp__supabase__execute_sqlto enrich (e.g. lookup project reference + title) whenentity=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:
- Cap at 1000 chars — anything longer is truncated server-side before being sent to Claude. The user sees a "(Auswahl gekürzt)" notice.
- Audit redaction:
paliadin_turns.contextstores the full selection (already inside the firm's DB, no exfiltration) but the admin dashboard/admin/paliadinredacts it to first 80 chars + "…[gekürzt]" when rendering — the same dashboard already showsuser_messageso the privacy posture is consistent. - 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 setsuser_selection_text=""regardless ofgetSelection().
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:
- 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.
- 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?"
- 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:
- 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.
- 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_KEYreserved-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
(
youpcAIRelayinterface inyoupc_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.goreference 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.
6.4 Relay-interface seam (small, optional, recommended)
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
-
User asks Paliadin: "Lege eine Frist für diese Akte an: 16.05. Klageerwiderung Acme".
-
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.Createwith the newIsAgentSuggestion=trueflag andagent_turn_id=<current turn>. - Inside the create-tx, after the entity insert, the existing approval
hookup runs:
ApprovalService.SubmitCreate(...). Critical change: whenIsAgentSuggestionis 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.
- Validates the user has visibility on the project (existing
-
Eye-pill 👀 + sparkle ✨ render on the resulting row in
/inbox,/deadlines,/agenda. Click → standard approve/reject UI. Approve flips status toapproved, setsdecision_kind='peer'(or admin_override if global_admin), the entity becomes live. -
Audit chain on the project's Verlauf:
deadline_approval_requestedevent withmetadata.requester_kind='agent'+metadata.agent_turn_id=<uuid>. Verlauf renderer picks this up and labels the event "Paliadin hat eine Frist vorgeschlagen ✨".deadline_approval_approvedwith the user asdecided_by+ the existingdecision_kindladder. Verlauf renders "Anna hat Paliadin's Vorschlag genehmigt ✨".
7.3 Why agent-suggested unconditionally goes through approval
Two reasons:
- 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.
- 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:
- Eye-pill row (
/inbox,/deadlines,/agenda, project detail, /events): 👀 ✨ side-by-side when applicable. Same.approval-pillshape, separate elements. - Audit log (
/admin/audit-log+ project Verlauf): the row's "approved by" line gets a trailing ✨ when the underlying request hadrequester_kind='agent'. Reads "Anna ✨ Schmidt" → tooltip "Über Paliadin vorgeschlagen, von Anna genehmigt". - 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
PaliadinRelayinterface extraction (skip if it makes the PR bigger without removing duplication).
Slice B — context payload + SKILL.md update (~1 commit)
- Wire structured
PaliadinContextfrom frontend → Go → tmux envelope. - SKILL.md
[ctx …]parsing + behaviour. client/paliadin-context.tsroute-table + entity extraction (one file)./api/paliadin/turnaccepts the new body shape (backwards-compatible: oldpage_originstill honoured ifcontextis 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.tsregistry (8 routes + default).- Mechanical pass: every authenticated TSX adds
<PaliadinWidget />. - CSS:
.paliadin-widget,.paliadin-drawer,.paliadin-trigger,.paliadin-context-chip, ~150 lines ofglobal.css. - ~30 i18n keys.
Slice D — agent-suggested write path (~1 commit)
paliad__suggest_deadline+paliad__suggest_appointmentMCP tools (or HTTP tool, depending on how the MCP scope already wires —internal/handlers/paliadin_tools.goif new file warranted).DeadlineService.Create/AppointmentService.Createaccept aIsAgentSuggestion bool+AgentTurnID *uuid.UUIDplumbed intoApprovalService.SubmitCreate(which gets a siblingSubmitAgentCreatethat 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--agentCSS.events.ts,agenda.ts,inbox.tsrender ✨ whenrequester_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}.sqlinternal/handlers/paliadin_tools.go(suggest verbs)internal/services/paliadin_relay.go(optional interface)frontend/src/components/PaliadinWidget.tsxfrontend/src/client/paliadin-widget.tsfrontend/src/client/paliadin-starters.tsfrontend/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(viascripts/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.comimmediately. 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).
Q12 — Recommended implementer?
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
/paliadinroute (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--agentCSS uses existing--color-*/--accent-*/--bg-softtokens. 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>&1on diagnostic — global rule. - i18n must compile — every new label gets a key in
client/i18n.ts- DE/EN values;
bun run buildregeneratesi18n-keys.ts.
- DE/EN values;
- Build + vet + test gate —
go build ./...+go vet ./...+go test ./...+cd frontend && bun run buildall 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
donelabel 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.