Proactive Actionable Nudge — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (
<input type=checkbox>) tracking.
Context
Goal: Turn the proactive glance into a one-click actionable notification: it proposes a concrete action, the notch expands with the scrolling message + a discreet sound, and clicking runs the exact voice path (spoken ack + dispatch with a fresh screenshot + spoken answer + open) as if the lawyer had said it. Nothing is persisted unless clicked.
Architecture: No new path. The glance agent returns an actionPrompt instead of a deep-link; runProactiveGlance stops creating a thread; the notch tap calls the existing sendTranscriptToClaudeWithScreenshot seeded with actionPrompt. The thread is created by that dispatch, on click.
Tech Stack: TypeScript (agent-runtime, chat-runtime, clicky-gateway), Swift/SwiftUI (Polaris notch). Bun + vitest; swiftc -typecheck for Swift.
Spec: docs/superpowers/specs/2026-06-12-proactive-actionable-nudge-design.md
File Structure
| File | Change |
|---|---|
packages/agent-runtime/src/tools/finalize-glance.ts | schema: +actionPrompt, −deepLink/openInBrowser |
packages/agent-runtime/src/agents/proactive-glance/prompt.ts | recalibrated, action-first |
packages/chat-runtime/src/glance.ts | returns {surface,message?,actionPrompt?}; no thread creation; fallback |
packages/chat-runtime/src/glance.test.ts | rewritten for the new shape |
apps/clicky-gateway/src/routes/router.ts | response body new shape (decision log already present) |
apps/polaris/leanring-buddy/Notch/NotchViewModel.swift | NudgePayload{message,actionPrompt} |
apps/polaris/leanring-buddy/Notch/NotchActivityCoordinator.swift | auto-hide wired into showActivity(duration:) |
apps/polaris/leanring-buddy/Notch/NotchView.swift | expanding scrolling message; tap → voice turn |
apps/polaris/leanring-buddy/ProactiveContextMonitor.swift | ProactiveGlanceClientResult{message,actionPrompt} |
apps/polaris/leanring-buddy/NorthDispatchAPI.swift | GlanceResponse decoder new shape |
apps/polaris/leanring-buddy/CompanionManager.swift | playNudgeSoundIfEnabled(); nudge-tap runs the voice turn; play sound on surface |
Task 1: finalize_glance schema — actionPrompt in, deepLink out
Files: packages/agent-runtime/src/tools/finalize-glance.ts + its test finalize-glance.test.ts.
Step 1: Update the test — open finalize-glance.test.ts, replace any deepLink/openInBrowser assertions with actionPrompt:
it("parses surface:true with message + actionPrompt", () => {
const msgs = [new AIMessage({ content: "", tool_calls: [{ type: "tool_call", name: "finalize_glance", id: "g",
args: { surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email" } }] })]
expect(harvestFinalizeGlance(msgs)).toEqual({
surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email",
})
})
it("parses surface:false", () => {
const msgs = [new AIMessage({ content: "", tool_calls: [{ type: "tool_call", name: "finalize_glance", id: "g",
args: { surface: false } }] })]
expect(harvestFinalizeGlance(msgs)).toEqual({ surface: false })
})
Step 2: Run → FAIL — cd packages/agent-runtime && bun test src/tools/finalize-glance.test.ts. Expected: the schema rejects/strips actionPrompt.
Step 3: Update the schema in finalize-glance.ts:
export const finalizeGlanceArgsSchema = z.object({
surface: z.boolean(),
// The line SHOWN in the notch (required when surface=true).
message: z.string().min(1).optional(),
// The imperative task RE-DISPATCHED when the lawyer taps the nudge — phrased
// as a first-person instruction they would speak ("Draft a reply to …").
actionPrompt: z.string().min(1).optional(),
})
Update the tool description: drop the openInBrowser sentence; add: "actionPrompt: the concrete task to run if the lawyer accepts — an imperative sentence they could have spoken (required when surface=true)."
Step 4: Run → PASS.
Step 5: Commit
git add packages/agent-runtime/src/tools/finalize-glance.ts packages/agent-runtime/src/tools/finalize-glance.test.ts
git commit -m "feat(glance): finalize_glance returns actionPrompt (drop deepLink/openInBrowser)"
Task 2: Recalibrate the proactive prompt
Files: packages/agent-runtime/src/agents/proactive-glance/prompt.ts (no test — it is a prompt string; the schema test covers structure).
Step 1: Rewrite the prompt
export const PROACTIVE_GLANCE_PROMPT = `You are a sharp senior legal assistant glancing — UNINVITED — at one screenshot of a lawyer's screen the moment they switched apps.
You were NOT asked anything. Decide whether there is ONE concrete ACTION you could take that the lawyer would plausibly accept with a single tap.
Lean toward proposing an action — but it must be a real, executable next step, named specifically from what is on screen (the matter, the sender, the document). Examples of screen → action:
- an email that clearly awaits a reply → offer to DRAFT the reply
- a title commitment / title report being reviewed → offer to DRAFT the exceptions disposition
- a contract being redlined → offer to DRAFT the next markup or a specific clause
- a matter / file open on screen → offer to pull the key dates, parties, or next actions
Call finalize_glance EXACTLY ONCE:
- surface: true ONLY when there is a concrete, useful action. message: ONE short English sentence the lawyer reads in a glance, naming what you saw and offering the action ("Mail from Riverside looks like it needs a reply — want me to draft it?"). actionPrompt: the imperative task to run if they accept, phrased as something they could have said aloud ("Draft a reply to the Riverside email about the title exceptions").
- surface: false for anything with no actionable next step — settings, a blank document, personal browsing, dashboards, nothing legal. This is a common, correct answer; when in doubt and there is no specific action worth a tap, stay silent.
Always answer in English. Respond ONLY by calling finalize_glance.`
Step 2: Typecheck — cd packages/agent-runtime && bun test src/agents/proactive-glance/ (the graph test still passes with the fake model; no prose assertion).
Step 3: Commit
git add packages/agent-runtime/src/agents/proactive-glance/prompt.ts
git commit -m "feat(glance): action-first proactive prompt with worked examples"
Task 3: runProactiveGlance — return actionPrompt, create no thread
Files: packages/chat-runtime/src/glance.ts + rewrite glance.test.ts.
Step 1: Rewrite the tests for the new shape (no deep-link, no thread, fallback):
it("returns surface + message + actionPrompt; creates NO thread", async () => {
const createThread = vi.fn(async () => "thr_x")
const result = await runProactiveGlance({
organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Mail", webBase: "https://web",
getAgentImpl: (async () => fakeGlanceGraph({
surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email",
})) as never,
createThreadImpl: createThread as never,
})
expect(result).toEqual({ surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email" })
expect(createThread).not.toHaveBeenCalled()
})
it("surface:false → no thread, no message", async () => {
const result = await runProactiveGlance({
organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Finder", webBase: "https://web",
getAgentImpl: (async () => fakeGlanceGraph({ surface: false })) as never,
})
expect(result).toEqual({ surface: false })
})
it("falls back actionPrompt←message when the model omitted actionPrompt", async () => {
const result = await runProactiveGlance({
organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Mail", webBase: "https://web",
getAgentImpl: (async () => fakeGlanceGraph({ surface: true, message: "Deadline in 2 days." })) as never,
})
expect(result).toEqual({ surface: true, message: "Deadline in 2 days.", actionPrompt: "Deadline in 2 days." })
})
Remove the ./threads mock and the createThreadImpl from the input type usage if no test needs it (keep the mock only if module load requires it — it does NOT once glance.ts stops importing createThread; remove the import).
Step 2: Run → FAIL — cd packages/chat-runtime && bun test src/glance.test.ts.
Step 3: Rewrite glance.ts
export interface RunProactiveGlanceInput {
organizationId: string
userId: string
screenshot: string
foregroundApp: string
webBase: string
getAgentImpl?: typeof getAgent
}
export interface ProactiveGlanceResult {
surface: boolean
message?: string
actionPrompt?: string
}
export async function runProactiveGlance(
input: RunProactiveGlanceInput,
): Promise<ProactiveGlanceResult> {
const getAgentFn = input.getAgentImpl ?? getAgent
const graph = await getAgentFn("proactive-glance")
const humanMessage = buildHumanMessageFromTrigger({
request: `The lawyer just switched into "${input.foregroundApp}". Here is their screen — decide whether there is one concrete action worth proposing.`,
screenshot: input.screenshot,
})
const final = await graph.invoke({ messages: [humanMessage] })
const glance = harvestFinalizeGlance(final.messages)
if (!glance || !glance.surface || !glance.message) return { surface: false }
// A surfaced nudge always carries something to dispatch on tap.
return {
surface: true,
message: glance.message,
actionPrompt: glance.actionPrompt ?? glance.message,
}
}
Delete the createThread import, the createThreadImpl seam, buildTasksDeepLink, and the webBase usage in the deep-link (keep webBase in the input type for now — it is harmless and the gateway still passes it; or drop it. Drop it: remove webBase from the input type and the gateway call in Task 4).
Step 4: Run → PASS.
Step 5: Commit
git add packages/chat-runtime/src/glance.ts packages/chat-runtime/src/glance.test.ts
git commit -m "feat(glance): runProactiveGlance returns actionPrompt, persists nothing"
Task 4: Gateway response shape
Files: apps/clicky-gateway/src/routes/router.ts.
Step 1: The route returns whatever runProactiveGlance returns (it already c.json(result)). Remove webBase from the runProactiveGlance({...}) call (it no longer takes it). The decision log stays but now reads result.surface + result.message (no deepLink):
console.log(
`[clicky-gateway] proactive-glance decision: surface=${result.surface}` +
(result.surface && result.message ? ` message="${result.message.slice(0, 80)}"` : "")
)
Step 2: cd apps/clicky-gateway && bun test (the route test, if any, still passes). bun run typecheck from root.
Step 3: Commit
git add apps/clicky-gateway/src/routes/router.ts
git commit -m "feat(gateway): proactive-glance returns the actionPrompt shape"
Task 5: Polaris — payload, decoder, monitor result
Files: NorthDispatchAPI.swift, ProactiveContextMonitor.swift, Notch/NotchViewModel.swift.
Step 1: GlanceResponse decoder (NorthDispatchAPI.swift):
private struct GlanceResponse: Decodable {
let surface: Bool
let message: String?
let actionPrompt: String?
}
// ... in proactiveGlance(...) return:
return ProactiveGlanceClientResult(
surface: decoded.surface,
message: decoded.message,
actionPrompt: decoded.actionPrompt
)
Step 2: ProactiveGlanceClientResult (ProactiveContextMonitor.swift):
struct ProactiveGlanceClientResult {
let surface: Bool
let message: String?
let actionPrompt: String?
}
In onSettled, the surface guard becomes: guard let result, result.surface, let message = result.message, let actionPrompt = result.actionPrompt else { return } then surface(message, actionPrompt). Change the surface closure type to (_ message: String, _ actionPrompt: String) -> Void.
Step 3: NudgePayload (Notch/NotchViewModel.swift):
struct NudgePayload: Equatable {
let message: String
let actionPrompt: String
}
Step 4: swiftc -typecheck (see Task 8 for the exact command). Expected: errors ONLY where call sites still pass deepLink — fixed in Tasks 6–7.
Step 5: Commit (after Tasks 6–7 typecheck clean, or commit now and fix forward — prefer committing the whole Swift change together at Task 7).
Task 6: Notch — expanding scrolling message + auto-hide
Files: Notch/NotchActivityCoordinator.swift, Notch/NotchView.swift.
Step 1: Wire auto-hide into showActivity (NotchActivityCoordinator.swift): call scheduleActivityHide() at the end of showActivity so a non-zero duration actually hides:
func showActivity(type: NotchActivityType, duration: TimeInterval = 0) {
activityDuration = duration
withAnimation(.smooth) {
expandingActivity = ExpandingActivity(show: true, type: type)
}
scheduleActivityHide() // honor `duration` (was never called before)
}
The CompanionManager surface closure calls showActivity(type: .nudge, duration: 14) (Task 7).
Step 2: Scrolling message subview — in NotchView.swift, the nudge content renders viewModel.pendingNudge?.message in a horizontally-scrolling text when it overflows. Minimal marquee:
// A one-line message that auto-scrolls horizontally if it overflows the notch
// width; short messages stay static. Uses DS tokens for color/typography.
struct ScrollingNudgeText: View {
let text: String
@State private var offset: CGFloat = 0
var body: some View {
GeometryReader { geo in
Text(text)
.font(DS.Fonts.caption)
.foregroundColor(DS.Colors.primaryText)
.fixedSize(horizontal: true, vertical: false)
.offset(x: offset)
.onAppear {
// Scroll only if the text is wider than the box.
withAnimation(.linear(duration: 8).repeatForever(autoreverses: true)) {
offset = -max(0, /* textWidth - */ geo.size.width * 0.5)
}
}
}
.frame(height: 16)
.clipped()
}
}
(Match the existing nudge styling in NotchView.swift; use the real DS token names already used in that file — replace DS.Fonts.caption/DS.Colors.primaryText with whatever the file already uses for nudge text.)
Step 3: swiftc -typecheck (Task 8 command).
Task 7: Notch tap = voice turn + sound + CompanionManager wiring
Files: Notch/NotchView.swift, CompanionManager.swift, Notch/NotchViewBridges.swift (the seam).
Step 1: Replace the deep-link tap (NotchView.swift ~line 101):
.onTapGesture {
if viewModel.status != .opened {
if hasNudge, let nudge = viewModel.pendingNudge {
// A nudge tap runs the proposed action as a full voice turn.
viewModel.clearNudge()
coordinator.hideActivity()
NotchViewBridges.shared.runProactiveAction?(nudge.actionPrompt)
} else {
viewModel.notchOpen(reason: .click)
}
}
}
Step 2: Add the seam (NotchViewBridges.swift): a closure the notch calls, set by CompanionManager.
/// Called when the lawyer taps a proactive nudge — runs `actionPrompt` as a
/// full voice turn (spoken ack + dispatch + spoken answer + open).
var runProactiveAction: ((String) -> Void)?
Step 3: Wire CompanionManager — in startProactiveMonitor()'s setup (or app start), set the seam to run the existing voice entry with the prompt:
NotchViewBridges.shared.runProactiveAction = { [weak self] actionPrompt in
guard let self else { return }
print("🛰️ proactive: nudge tapped → running action as a voice turn")
ClickyAnalytics.trackUserMessageSent(transcript: actionPrompt)
self.lastTranscript = actionPrompt
// Same entry the push-to-talk shortcut calls on a finalized transcript —
// it recaptures a fresh screenshot, speaks the ack, dispatches, and opens.
self.sendTranscriptToClaudeWithScreenshot(transcript: actionPrompt)
}
(Confirm sendTranscriptToClaudeWithScreenshot recaptures the screenshot internally — it does, it is the same routine the shortcut's submitDraftText calls. If it takes a screenshot param, pass a fresh captureCursorScreenBase64().)
Step 4: Discreet sound on surface — add to CompanionManager.swift:
/// A soft, distinct "coucou" when a proactive nudge appears — lighter than the
/// voice-turn "Tink", same preference + not-frontmost guards.
private func playNudgeSoundIfEnabled() {
guard UserDefaults.standard.object(forKey: "notchSoundEnabled") as? Bool ?? true else { return }
guard !NSApplication.shared.isActive else { return }
NSSound(named: "Pop")?.play()
}
Call it in the surface closure (the one that sets pendingNudge + showActivity(.nudge)): set the new NudgePayload(message:actionPrompt:), showActivity(type: .nudge, duration: 14), triggerBounce(), and playNudgeSoundIfEnabled().
Step 5: swiftc -typecheck → clean (Task 8 command; 0 errors outside the pre-existing PostHog/Mixpanel noise).
Step 6: Commit the whole Swift change
git add apps/polaris/leanring-buddy/Notch/NotchViewModel.swift \
apps/polaris/leanring-buddy/Notch/NotchActivityCoordinator.swift \
apps/polaris/leanring-buddy/Notch/NotchView.swift \
apps/polaris/leanring-buddy/Notch/NotchViewBridges.swift \
apps/polaris/leanring-buddy/ProactiveContextMonitor.swift \
apps/polaris/leanring-buddy/NorthDispatchAPI.swift \
apps/polaris/leanring-buddy/CompanionManager.swift
git commit -m "feat(polaris): actionable nudge — scrolling message, discreet sound, tap runs a voice turn"
Task 8: Full verification
Step 1: TS — bun run typecheck && bun run lint from root → 0 errors.
Step 2: Touched-package tests (export surfaces changed):
cd packages/agent-runtime && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run src/tools src/agents/proactive-glance
cd ../chat-runtime && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run
cd ../../apps/clicky-gateway && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run
Step 3: Swift typecheck (TCC-safe, no xcodebuild):
cd apps/polaris/leanring-buddy
SDK=$(xcrun --show-sdk-path)
swiftc -typecheck -sdk "$SDK" -target arm64-apple-macos14.0 -module-name lb -I /tmp/phmod \
$(find . -name "*.swift" ! -name "leanring_buddyApp.swift") 2>&1 | grep "error:" | grep -ivE "PostHogSDK|Mixpanel"
# expect: no output (the only errors are the pre-existing PostHog/Mixpanel SDK noise)
Step 4: Push — git push. (Server side is live on cosmic's --watch; Polaris needs a Cmd+R rebuild for the Swift.)
Notes for the executor
- Server vs Polaris: Tasks 1–4 are server-side (live on cosmic after pull, no redeploy). Tasks 5–7 are Polaris and need a Cmd+R rebuild to take effect.
- xcodebuild is BARRED — verify Swift only via
swiftc -typecheck. The PostHog/Mixpanel "not in scope" errors are pre-existing SDK-resolution noise, not yours. - DS tokens: in Task 6, use the exact
DS.*names already present inNotchView.swiftfor nudge text — do not invent token names. - No thread until click is the load-bearing economy: confirm
runProactiveGlancehas nocreateThreadpath after Task 3.