How to build, test and analyze thunks with the Thunk.AI Builder MCP, a Thunk pane with your thunks, and next-step suggestions. Installed with a tenant plugin.

Build, test and analyze Thunk.AI thunks from Claude: AI agents that carry out a business process step by step, with your tools and human review where you need it.
This plugin carries:
thunk-guide skill: which Thunk.AI help articles to read, the order to build a thunk in, and how to test, troubleshoot and clean up a thunk./thunk <name or id> opens a thunk, /thunk the pane, and /thunk-welcome shows the welcome again. These show in the Claude desktop app's Code tab. In the terminal nothing shows unless you run /thunk or /thunk-welcome, and they do not run in claude.ai or Cowork.The Builder MCP tools come from the plugin for your Thunk.AI tenant, which installs this one with it.
Add the Thunk.AI marketplace (the public one is below; a private edition comes with its own), then install your tenant's plugin from it with /plugin:
claude plugin marketplace add ThunkAI/thunk-plugins
Then run /mcp, select its thunk-builder server, choose Authenticate and sign in with your Thunk.AI account. The Thunk.AI pane opens at the next session start and lists your thunks once you are signed in.
| Path | What it is |
|---|---|
skills/thunk-guide/ | The skill, built from help-center articles (not committed in thunk-web) |
hooks/register.tsx | The Claude Code hooks module: the pane, the next-step band and system prompt rule |
hooks/pane.test.tsx | Its tests: claude plugin test . |
types/index.d.ts | The module's $.state contract |
plugin.json | Codex's manifest; .claude-plugin/plugin.json is Claude's |
Apache-2.0
hooks/register.tsx 887 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Account, CurrentThunk, NextStep, Thunk } from "../types";
5
6const PANE = "thunk-home";
7const TITLE = "Thunk.AI";
8
9// Thunk.AI's amber accent (the webapp theme's info and folder accent). Only the mark and the
10// welcome card's border use it: as text on a light background it is too faint to read, so
11// words keep the person's theme colors.
12const AMBER = "#eaa606";
13
14// A Builder MCP tool's name: mcp__plugin_<tenant plugin>_thunk-builder__<tool> for a tenant
15// plugin's server, mcp__<server>__<tool> for one added by hand. No tenant is named here: the
16// public and private exports refuse a file that names another edition's tenant.
17const BUILDER_TOOL = /^mcp__(.+)__([a-z_]+)$/;
18
19const account = atom(
20 { plugin: "thunk-skills", key: "account" } as const,
21 {
22 kind: "unknown",
23 } as Account,
24);
25// Whether the pane shows the welcome text: on first run, and again from "About Thunk.AI".
26const isNew = atom({ plugin: "thunk-skills", key: "isNew" } as const, false);
27const next = atom(
28 { plugin: "thunk-skills", key: "next" } as const,
29 null as NextStep | null,
30);
31// Whether thunk-home is open, set on every open and cleared by ui.close. The band reads it so
32// it redraws when the pane closes; $.ui.panes() alone would not redraw it.
33const isPaneOpen = atom(
34 { plugin: "thunk-skills", key: "isPaneOpen" } as const,
35 false,
36);
37// The Builder tools whose thunkId is the thunk the person is working on (not, say, copy_thunk's
38// source or a comparison).
39const WORKS_ON = new Set([
40 "get_thunk",
41 "get_definition",
42 "get_errors",
43 "run_work_item",
44 "rerun_work_item",
45 "create_work_item",
46 "query_work_items",
47 "run_review",
48 "batch_steps",
49 "batch_properties",
50 "batch_tools",
51 "batch_connections",
52]);
53
54// The name in a get_thunk result, when there is one.
55function thunkName(text: string | undefined) {
56 try {
57 return cleanName((JSON.parse(text ?? "") as { name?: unknown }).name);
58 } catch {
59 return undefined;
60 }
61}
62
63// The thunk the person is working on: the one they opened, the one Claude opened in the browser
64// pane, or the one Claude last worked on through the Builder tools. The band offers actions on it.
65const current = atom(
66 { plugin: "thunk-skills", key: "current" } as const,
67 null as CurrentThunk | null,
68);
69// The person hid the band's "Open Thunk.AI pane" button; kept in $.store across sessions.
70const isChipHidden = atom(
71 { plugin: "thunk-skills", key: "isChipHidden" } as const,
72 false,
73);
74
75const STARTERS: NextStep[] = [
76 {
77 label: "Build my first thunk",
78 prompt:
79 "I am new to Thunk.AI. Interview me briefly about a business process I want to automate, then build it as a thunk and open it in the browser pane so I can watch.",
80 },
81 {
82 label: "Take a guided tour",
83 prompt:
84 "Give me a guided tour of Thunk.AI in the browser pane, using one of the public sample thunks: what a thunk is, its workflow plan, steps, tools and a work item run.",
85 },
86 {
87 label: "What can Thunk.AI do for me?",
88 prompt:
89 "Ask me what my role and team are, then suggest three processes Thunk.AI could automate for me, with one sentence each on how.",
90 },
91];
92
93// After Claude uses a Builder tool, the most useful next move.
94const FOLLOW_UPS: Record<string, NextStep> = {
95 create_thunk: {
96 label: "Run a test work item",
97 prompt:
98 "Create a realistic test work item on the thunk we just built, run it and walk me through what each step did.",
99 },
100 batch_steps: {
101 label: "Test the new steps",
102 prompt:
103 "Run a work item through the steps we just changed and tell me whether each one did what it should.",
104 },
105 run_work_item: {
106 label: "Review the run",
107 prompt:
108 "Review the work item that just ran: where it spent time, any errors or weak outputs, and what to change.",
109 },
110 get_errors: {
111 label: "Fix the cause",
112 prompt:
113 "Propose the smallest change that fixes the errors we just found, then rerun the failing work item.",
114 },
115 batch_connections: {
116 label: "Try the connection",
117 prompt:
118 "Run a work item that uses the connection we just set up and confirm its tools work end to end.",
119 },
120 run_review: {
121 label: "Apply the review",
122 prompt:
123 "Apply the top recommendations from that review to the thunk, then rerun a work item to compare.",
124 },
125};
126
127// How a prompt names a thunk: by name when the pane knows it, always by id.
128// thunk names and ids come from server data and URLs, and go into prompts submitted as the
129// person's own words, so neither may carry quotes, line breaks or other control characters.
130const THUNK_ID = /^[A-Za-z0-9_=+-]{1,64}$/;
131
132function cleanId(id: unknown) {
133 return typeof id === "string" && THUNK_ID.test(id) ? id : undefined;
134}
135
136function cleanName(name: unknown) {
137 const text =
138 typeof name === "string"
139 ? name
140 .replace(/[\u0000-\u001f\u007f"“”`]/g, " ")
141 .replace(/\s+/g, " ")
142 .trim()
143 : "";
144 return text ? text.slice(0, 80) : undefined;
145}
146
147function thunkRef(thunk: CurrentThunk) {
148 return thunk.name
149 ? `"${thunk.name}" (id ${thunk.id})`
150 : `with id ${thunk.id}`;
151}
152
153// What the person can ask about the thunk they are working on, above the prompt.
154const THUNK_ACTIONS: {
155 key: string;
156 label: string;
157 prompt: (thunk: CurrentThunk) => string;
158}[] = [
159 {
160 key: "analyze",
161 label: "Analyze",
162 prompt: (thunk) =>
163 `Analyze my thunk ${thunkRef(thunk)}: what it does, how its recent work items went (passed, failed, time taken) and anything that looks wrong.`,
164 },
165 {
166 key: "explain",
167 label: "Explain",
168 prompt: (thunk) =>
169 `Explain my thunk ${thunkRef(thunk)} in plain terms: its goal, its workflow plan and steps, the tools and connections it uses, and where people review its work.`,
170 },
171 {
172 key: "debug",
173 label: "Debug errors",
174 prompt: (thunk) =>
175 `Find the errors and failed work items on my thunk ${thunkRef(thunk)}, explain the cause of each, and propose the smallest fix. Don't change anything yet.`,
176 },
177 {
178 key: "improve",
179 label: "Suggest improvements",
180 prompt: (thunk) =>
181 `Suggest the three most useful improvements to my thunk ${thunkRef(thunk)} (reliability, speed, cost, or the clarity of its instructions), most valuable first. Don't change anything yet.`,
182 },
183];
184
185function openThunkStep(target: string): NextStep {
186 return {
187 label: "Open",
188 prompt: `Open my thunk ${target} in the browser pane.`,
189 };
190}
191
192// The Thunk.AI app's page for one thunk, opened in the browser pane: a thunk.ai host (any
193// tenant's) and a /thunk/<id> path. The URL is parsed, never matched as text.
194function thunkPageId(url: unknown) {
195 try {
196 const page = new URL(String(url));
197 const isThunkHost =
198 page.hostname === "thunk.ai" || page.hostname.endsWith(".thunk.ai");
199 const path = /^\/thunk\/([^/]+)/.exec(page.pathname);
200 return page.protocol === "https:" && isThunkHost && path?.[1]
201 ? cleanId(decodeURIComponent(path[1]))
202 : undefined;
203 } catch {
204 return undefined;
205 }
206}
207
208// The URLs a call on the side-panel browser opens: navigate and preview_start take a url, and
209// browser_batch carries navigate actions.
210function browserUrls(tool: string, input: Record<string, unknown>) {
211 // A cloud session linked to the desktop app reaches the same browser as remote-devices.
212 const name = /^mcp__(?:remote-devices__)?Claude_Browser__(.+)$/.exec(
213 tool,
214 )?.[1];
215 if (name === "navigate" || name === "preview_start") {
216 return [input.url];
217 }
218 if (name === "browser_batch" && Array.isArray(input.actions)) {
219 return (input.actions as { name?: unknown; input?: { url?: unknown } }[])
220 .filter((action) => action?.name === "navigate")
221 .map((action) => action.input?.url);
222 }
223 return [];
224}
225
226const NEXT_STEPS_RULE = [
227 "This user works with Thunk.AI through the Thunk.AI Builder tools.",
228 'After any turn in which you built, changed, ran or analyzed a thunk, end your reply with a short "Next steps" list of two or three concrete actions they could take on that thunk now (for example: run a test work item, add a review step, connect a system, export it as an MCP server), most useful first.',
229 "For a user who is new to Thunk.AI, explain any Thunk.AI term (thunk, step, work item, connection) in a few words the first time you use it.",
230].join(" ");
231
232type Shown = {
233 thunkId?: unknown;
234 name?: unknown;
235 url?: unknown;
236 productionMode?: unknown;
237 folderPath?: unknown;
238};
239
240// The pane never calls the Builder server itself: a plugin's own MCP call goes through the
241// person's permissions with no one to approve it (auto mode refuses it outright), and a plugin
242// cannot approve its own call in the desktop app. It shows the thunks from the last unfiltered
243// list_thunks Claude ran instead, kept in $.store so the next session starts with them.
244const SHOWN = 8;
245
246// The thunks in a list_thunks result, or undefined when the call was filtered (a search, a
247// folder, a tag, a later page, shared or samples only), so the pane never shows a partial list
248// as "your thunks".
249function listedThunks(
250 input: Record<string, unknown>,
251 text: string | undefined,
252) {
253 const isFiltered =
254 ["search", "folder", "tag", "after"].some(
255 (key) => input[key] !== undefined,
256 ) ||
257 (input.access !== undefined &&
258 input.access !== "owned" &&
259 input.access !== "all");
260 if (isFiltered || !text) {
261 return undefined;
262 }
263 try {
264 const listed = JSON.parse(text) as { items?: unknown };
265 const items = (Array.isArray(listed.items) ? listed.items : []) as Shown[];
266 return items
267 .filter((item) => cleanId(item.thunkId))
268 .slice(0, SHOWN)
269 .map(
270 (item): Thunk => ({
271 id: cleanId(item.thunkId) ?? "",
272 name: cleanName(item.name) ?? "Untitled thunk",
273 url: String(item.url),
274 phase: String(item.productionMode ?? ""),
275 folder: String(item.folderPath ?? "/"),
276 }),
277 );
278 } catch {
279 return undefined;
280 }
281}
282
283// The pane, the band and the status line are for the Claude apps. In the terminal they crowd
284// the prompt and take Tab stops, so a terminal session shows none of them unasked: /thunk and
285// /thunk-welcome still open the pane there. The desktop app attaches its surface after
286// session.start, so the pane opens on the first app surface: at session.start when one is
287// already there, else at session.attach. Module state: a reload runs session.start again.
288let isInApp = false;
289
290async function welcomeApp($: EngineInterface) {
291 if (isInApp) {
292 return;
293 }
294 isInApp = true;
295 const state = await read($, account);
296 if (state.kind === "ready") {
297 await showThunks($, state.thunks, state.listedAt);
298 }
299 void openPane($, false).catch(() => undefined);
300 if (await read($, isNew)) {
301 $.ui.toast(
302 "Welcome to Thunk.AI. Pick something to try in the Thunk.AI pane.",
303 );
304 }
305}
306
307async function showThunks(
308 $: EngineInterface,
309 thunks: Thunk[],
310 listedAt: number,
311) {
312 await update(
313 $,
314 account,
315 () => ({ kind: "ready", thunks, listedAt }) as Account,
316 );
317 if (isInApp) {
318 $.ui.status(
319 `Thunk.AI · ${thunks.length === SHOWN ? `${SHOWN}+` : thunks.length} thunks`,
320 );
321 }
322}
323
324// The commands the pane lists. Claude loads the thunk-guide skill on its own when a request
325// needs it, so the skill is not listed.
326const COMMANDS: [string, string][] = [
327 ["/thunk <name or id>", "open a thunk in the browser pane"],
328 ["/thunk", "open this pane"],
329 ["/thunk-welcome", "show the welcome again"],
330 ["/mcp", "sign in to the Thunk.AI Builder server"],
331];
332
333const LIST_THUNKS: NextStep = {
334 label: "Show my thunks",
335 prompt: `List my ${SHOWN} most recently changed thunks (list_thunks with access "owned" and first ${SHOWN}, no other filter), as a short table of name, phase and folder.`,
336};
337
338async function openPane($: EngineInterface, focus: boolean) {
339 await $.ui.open({
340 id: PANE,
341 title: TITLE,
342 ...(focus ? { focus: true } : {}),
343 });
344 await update($, isPaneOpen, () => true);
345 // isPaneOpen may already have been true (a pane waiting on a narrow terminal, or behind
346 // another tab), so redraw the band for what $.ui.panes() now says.
347 $.ui.invalidate("ui.render");
348}
349
350// Whether the person can see the pane now: open, drawn (not waiting on a narrow terminal) and
351// the tab in front.
352async function isPaneVisible($: EngineInterface) {
353 if (!(await read($, isPaneOpen))) {
354 return false;
355 }
356 const pane = (await $.ui.panes().catch(() => [])).find(
357 (one) => one.id === PANE,
358 );
359 return pane !== undefined && pane.isPlaced && pane.isShown;
360}
361
362async function setChipHidden($: EngineInterface, hidden: boolean) {
363 await $.store.set("isChipHidden", hidden);
364 await update($, isChipHidden, () => hidden);
365}
366
367// Sets the current thunk; true when it changed.
368async function setCurrent($: EngineInterface, id: string, name?: string) {
369 const state = await read($, account);
370 const known =
371 cleanName(name) ??
372 (state.kind === "ready"
373 ? state.thunks.find((one) => one.id === id)?.name
374 : undefined);
375 const before = await read($, current);
376 await update($, current, () => ({ id, ...(known ? { name: known } : {}) }));
377 return before?.id !== id;
378}
379
380// The desktop app stacks the pane and the browser pane in one column and splits its height, so
381// the pane steps aside while a thunk is open in the browser; the band's "Thunk.AI pane" brings it
382// back.
383// The thunk whose page last made the pane step aside. Module state: a reload starts it over.
384let steppedAsideFor: string | null = null;
385
386async function closePane($: EngineInterface) {
387 // Asks the engine rather than isPaneOpen, which a reload starts over at false.
388 const panes = await $.ui.panes().catch(() => []);
389 if (panes.some((one) => one.id === PANE)) {
390 await $.ui.close({ id: PANE });
391 }
392 await update($, isPaneOpen, () => false);
393}
394
395// A prompt a slash command asked for. A command.run hook cannot submit one (it would wait on the
396// turn the command holds), so it is submitted when that turn completes.
397let pendingAsk: NextStep | null = null;
398
399function ask($: EngineInterface, step: NextStep) {
400 void update($, next, () => null);
401 // Every prompt the plugin submits comes from a button the person pressed, so it goes in as
402 // their own words, without the frame Claude Code puts around a plugin's message.
403 void $.prompt.submit({ text: step.prompt, asUser: true });
404}
405
406export const register: Register = (on) => {
407 on("session.start", async ($, e, nextHook) => {
408 await $.command.register({
409 name: "thunk",
410 description:
411 "Open a thunk in the browser pane, or with no name the Thunk.AI pane",
412 argumentHint: "[thunk name or id]",
413 });
414 await $.command.register({
415 name: "thunk-welcome",
416 description: "Show the Thunk.AI welcome again",
417 });
418
419 isInApp = false;
420 const hasVisited = (await $.store.get("hasVisited")) === true;
421 await update($, isNew, () => !hasVisited);
422 const chipHidden = (await $.store.get("isChipHidden")) === true;
423 await update($, isChipHidden, () => chipHidden);
424 const cached = (await $.store.get("thunks")) as
425 | { thunks: Thunk[]; listedAt: number }
426 | undefined;
427 if (cached && Array.isArray(cached.thunks)) {
428 await showThunks($, cached.thunks, cached.listedAt);
429 }
430 const surfaces = await $.session.surfaces().catch(() => []);
431 if (e.isInteractive && surfaces.some((one) => one !== "terminal")) {
432 await welcomeApp($);
433 }
434
435 return nextHook(e);
436 });
437
438 on("session.attach", async ($, e, nextHook) => {
439 const attached = await nextHook(e);
440 if (e.surface !== "terminal") {
441 await welcomeApp($).catch(() => undefined);
442 }
443
444 return attached;
445 });
446
447 on("command.run", { command: "thunk" }, async ($, e) => {
448 const target = e.args.trim();
449 if (target) {
450 pendingAsk = openThunkStep(
451 `"${cleanName(target) ?? target}" (a name or an id)`,
452 );
453
454 return { text: `Opening thunk ${target}.` };
455 }
456 await setChipHidden($, false);
457 await openPane($, true);
458
459 return { text: "Thunk.AI pane opened." };
460 });
461
462 on("command.run", { command: "thunk-welcome" }, async ($) => {
463 await update($, isNew, () => true);
464 await openPane($, true);
465
466 return { text: "Thunk.AI welcome shown." };
467 });
468
469 on("ui.close", { id: PANE }, async ($, e, nextHook) => {
470 const closed = await nextHook(e);
471 // A hook beneath may have kept the pane open, so ask rather than assume.
472 const panes = await $.ui.panes().catch(() => []);
473 await update($, isPaneOpen, () => panes.some((one) => one.id === PANE));
474
475 return closed;
476 }).catch(($, e, nextHook) => nextHook(e));
477
478 on("prompt.compose", async ($, e, nextHook) => {
479 const composed = await nextHook(e);
480
481 return {
482 sections: [
483 ...composed.sections,
484 {
485 id: "thunk-skills:next-steps",
486 text: NEXT_STEPS_RULE,
487 scope: "session" as const,
488 },
489 ],
490 };
491 });
492
493 // The person moved on: a suggestion is for the turn that raised it.
494 on("prompt.submit", async ($, e, nextHook) => {
495 await update($, next, () => null).catch(() => undefined);
496 // A prompt of the person's own supersedes one a command left to submit.
497 if (e.origin.kind !== "plugin") {
498 pendingAsk = null;
499 }
500
501 return nextHook(e);
502 });
503
504 on("tool.call", async ($, e, nextHook) => {
505 const ran = await nextHook(e);
506 const match = BUILDER_TOOL.exec(e.tool);
507 const followUp =
508 match?.[1] && /thunk/i.test(match[1]) && match[2]
509 ? FOLLOW_UPS[match[2]]
510 : undefined;
511 if (
512 followUp &&
513 !("deny" in ran && ran.deny) &&
514 !("isError" in ran && ran.isError)
515 ) {
516 await update($, next, () => followUp).catch(() => undefined);
517 }
518
519 const isOk =
520 !("deny" in ran && ran.deny) && !("isError" in ran && ran.isError);
521 const thunks =
522 isOk && match?.[2] === "list_thunks"
523 ? listedThunks(e as unknown as Record<string, unknown>, ran.text)
524 : undefined;
525 if (thunks) {
526 const listedAt = await $.clock.now();
527 await $.store.set("thunks", { thunks, listedAt });
528 await showThunks($, thunks, listedAt).catch(() => undefined);
529 }
530
531 // The main loop's calls only: a subagent's sweep over thunks is not what the person has open.
532 const input = e as unknown as Record<string, unknown>;
533 const isMainLoop = !input.agentId;
534 const builderThunk =
535 isOk &&
536 isMainLoop &&
537 match?.[1] &&
538 /thunk/i.test(match[1]) &&
539 match[2] &&
540 WORKS_ON.has(match[2])
541 ? cleanId(input.thunkId)
542 : undefined;
543 if (builderThunk) {
544 await setCurrent(
545 $,
546 builderThunk,
547 match?.[2] === "get_thunk" ? thunkName(ran.text) : undefined,
548 ).catch(() => false);
549 }
550 const pageId =
551 isOk && isMainLoop
552 ? browserUrls(e.tool, input).map(thunkPageId).find(Boolean)
553 : undefined;
554 // The pane steps aside once per thunk opened, so after the person brings it back, moving
555 // around the same thunk's pages leaves it. Keyed on the page, not on the current thunk:
556 // Open sets the current thunk before Claude opens its page.
557 if (pageId) {
558 await setCurrent($, pageId).catch(() => false);
559 if (pageId !== steppedAsideFor) {
560 steppedAsideFor = pageId;
561 await closePane($).catch(() => undefined);
562 }
563 }
564
565 return ran;
566 });
567
568 on("turn.complete", async ($, e, nextHook) => {
569 const done = await nextHook(e);
570 if (e.agentId) {
571 return done;
572 }
573 if (pendingAsk) {
574 const asked = pendingAsk;
575 pendingAsk = null;
576 // A turn the person stopped also stops what the command asked for.
577 if (!e.isAborted) {
578 ask($, asked);
579
580 return done;
581 }
582 }
583 const step = await read($, next);
584 if (step) {
585 void $.prompt.suggest({ text: step.prompt });
586 }
587
588 return done;
589 });
590
591 // The band above the prompt: a next step after a Builder tool call; otherwise, while the pane
592 // is out of sight and no other plugin draws here, a way back to it.
593 on("ui.render", { component: "AbovePrompt" }, async ($, e, nextHook) => {
594 if (e.props.hasSurvey || e.surface === "terminal") {
595 return nextHook(e);
596 }
597
598 const { Box, Button, Text } = $.ui.resolve(e);
599 // Whatever another plugin (or the engine) draws here stays, under the Thunk.AI row.
600 const below = await nextHook(e);
601 // While Claude works the thunk row stays (a press queues its prompt for after the turn); the
602 // next step and the chip wait, since the turn may change them.
603 const isWorking = e.props.isWorking;
604
605 const step = isWorking ? null : await read($, next);
606 const thunk = await read($, current);
607 const paneVisible = await isPaneVisible($);
608 const showChip =
609 !isWorking &&
610 !paneVisible &&
611 !(await read($, isChipHidden)) &&
612 below.type === "engine";
613 if (!step && !thunk && !showChip) {
614 return below;
615 }
616
617 return (
618 <Box flexDirection="column">
619 {thunk && (
620 <Box flexDirection="column">
621 <Box gap={1}>
622 <Text color={AMBER}>●</Text>
623 <Text bold>{thunk.name ?? "This thunk"}</Text>
624 {!paneVisible && (
625 <Button
626 key="thunk-pane"
627 plain
628 dimColor
629 onPress={() => openPane($, true)}
630 >
631 Thunk.AI pane
632 </Button>
633 )}
634 <Button
635 key="clear-thunk"
636 plain
637 dimColor
638 onPress={() => update($, current, () => null)}
639 >
640 Close
641 </Button>
642 </Box>
643 <Box gap={1} flexWrap="wrap" paddingLeft={2}>
644 {THUNK_ACTIONS.map((action) => (
645 <Button
646 key={`do-${action.key}`}
647 onPress={() =>
648 ask($, {
649 label: action.label,
650 prompt: action.prompt(thunk),
651 })
652 }
653 >
654 {action.label}
655 </Button>
656 ))}
657 </Box>
658 </Box>
659 )}
660 {step && (
661 <Box gap={1}>
662 <Text color={AMBER}>●</Text>
663 <Text bold>Thunk.AI next step:</Text>
664 <Button key="next" variant="primary" onPress={() => ask($, step)}>
665 {step.label}
666 </Button>
667 <Button
668 key="dismiss"
669 role="dismiss"
670 dimColor
671 onPress={() => update($, next, () => null)}
672 >
673 Not now
674 </Button>
675 </Box>
676 )}
677 {!thunk && showChip && (
678 <Box gap={1}>
679 <Text color={AMBER}>●</Text>
680 <Button key="open-pane" plain onPress={() => openPane($, true)}>
681 Open Thunk.AI pane
682 </Button>
683 <Button
684 key="hide-chip"
685 role="dismiss"
686 plain
687 dimColor
688 onPress={() => setChipHidden($, true)}
689 >
690 Hide
691 </Button>
692 </Box>
693 )}
694 {below}
695 </Box>
696 );
697 });
698
699 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
700 const { Box, Button, Link, Text } = $.ui.resolve(e);
701 const state = await read($, account);
702 const showWelcome = await read($, isNew);
703 const step = await read($, next);
704
705 // Closing the welcome marks it seen; "About Thunk.AI" shows it again without changing that.
706 const closeWelcome = async () => {
707 await $.store.set("hasVisited", true);
708 await update($, isNew, () => false);
709 };
710 const example =
711 state.kind === "ready" && state.thunks[0]
712 ? `"${state.thunks[0].name}"`
713 : '"Invoice intake"';
714
715 // The starters lead for a new user and follow everything else once the welcome is closed.
716 const starters = (isLead: boolean) => (
717 <Box flexDirection="column" gap={isLead ? 1 : 0}>
718 <Text bold>{isLead ? "Get started" : "New to Thunk.AI?"}</Text>
719 {STARTERS.map((starter, index) => (
720 <Button
721 key={`starter-${index}`}
722 hotkey={String(index + 1)}
723 plain={!isLead || undefined}
724 variant={isLead && index === 0 ? "primary" : "secondary"}
725 onPress={async () => {
726 await closeWelcome();
727 ask($, starter);
728 }}
729 >
730 {starter.label}
731 </Button>
732 ))}
733 {!isLead && (
734 <Button
735 key="about"
736 plain
737 dimColor
738 onPress={() => update($, isNew, () => true)}
739 >
740 About Thunk.AI
741 </Button>
742 )}
743 </Box>
744 );
745
746 return (
747 <Box flexDirection="column" gap={1} paddingX={1}>
748 <Box gap={1}>
749 <Text color={AMBER} bold>
750 ●
751 </Text>
752 <Text bold>Thunk.AI</Text>
753 </Box>
754
755 {showWelcome && (
756 <Box
757 flexDirection="column"
758 borderStyle="round"
759 borderColor={AMBER}
760 paddingX={1}
761 gap={1}
762 >
763 <Text bold>Welcome to Thunk.AI</Text>
764 <Text>
765 Thunks are AI agents that run your business processes step by
766 step, with your tools and human review where you need it. Claude
767 can build, test and fix them with you right here.
768 </Text>
769 <Button key="close-welcome" dimColor onPress={closeWelcome}>
770 Got it
771 </Button>
772 </Box>
773 )}
774
775 {step && (
776 <Box flexDirection="column" gap={1}>
777 <Text bold>Suggested next</Text>
778 <Button
779 key="pane-next"
780 variant="primary"
781 onPress={() => ask($, step)}
782 >
783 {step.label}
784 </Button>
785 </Box>
786 )}
787
788 {showWelcome && starters(true)}
789
790 <Box flexDirection="column" gap={1}>
791 <Box gap={1}>
792 <Text bold>Your thunks</Text>
793 {state.kind === "ready" && (
794 <Button
795 key="refresh"
796 plain
797 dimColor
798 onPress={() => ask($, LIST_THUNKS)}
799 >
800 ↻ Refresh
801 </Button>
802 )}
803 </Box>
804 {state.kind === "unknown" && (
805 <Box flexDirection="column" gap={1}>
806 <Text dimColor>
807 Claude lists them here once it has looked them up.
808 </Text>
809 <Button key="show-thunks" onPress={() => ask($, LIST_THUNKS)}>
810 {LIST_THUNKS.label}
811 </Button>
812 </Box>
813 )}
814 {state.kind === "ready" && (
815 <Text dimColor>
816 As of {new Date(state.listedAt).toLocaleString()}
817 </Text>
818 )}
819 {state.kind === "ready" && state.thunks.length === 0 && (
820 <Text dimColor>
821 No thunks yet. Start with "Build my first thunk".
822 </Text>
823 )}
824 {state.kind === "ready" &&
825 state.thunks.map((thunk) => (
826 <Box key={`row-${thunk.id}`} flexDirection="column">
827 <Text>
828 {thunk.name} <Text dimColor>{thunk.phase}</Text>
829 </Text>
830 <Box gap={1}>
831 <Button
832 key={`open-${thunk.id}`}
833 onPress={async () => {
834 await setCurrent($, thunk.id, thunk.name);
835 ask($, openThunkStep(thunkRef(thunk)));
836 }}
837 >
838 Open
839 </Button>
840 <Button
841 key={`analyze-${thunk.id}`}
842 plain
843 onPress={async () => {
844 await setCurrent($, thunk.id, thunk.name);
845 ask($, {
846 label: "Analyze",
847 prompt: THUNK_ACTIONS[0]!.prompt(thunk),
848 });
849 }}
850 >
851 Analyze
852 </Button>
853 <Link href={thunk.url} label="Open in Thunk.AI" />
854 </Box>
855 <Text dimColor>{thunk.folder}</Text>
856 </Box>
857 ))}
858 </Box>
859
860 <Box flexDirection="column">
861 <Text bold>Ask Claude to…</Text>
862 {[
863 `Analyze my thunk ${example}: why are its work items failing?`,
864 `Run a test work item on ${example} and walk me through each step`,
865 `Explain how ${example} decides when to ask for review`,
866 ].map((line, index) => (
867 <Text key={`example-${index}`} dimColor>
868 “{line}”
869 </Text>
870 ))}
871 </Box>
872
873 <Box flexDirection="column">
874 <Text bold>Commands</Text>
875 {COMMANDS.map(([name, what]) => (
876 <Text key={`command-${name}`}>
877 {name} <Text dimColor>{what}</Text>
878 </Text>
879 ))}
880 </Box>
881
882 {!showWelcome && starters(false)}
883 </Box>
884 );
885 });
886};
887types/index.d.ts 32 lines1export type Thunk = {
2 id: string;
3 name: string;
4 url: string;
5 phase: string;
6 folder: string;
7};
8
9// What the pane knows about the person's thunks: nothing yet (Claude has not listed them in
10// this or an earlier session), or the last unfiltered list Claude ran and when.
11export type Account =
12 | { kind: "unknown" }
13 | { kind: "ready"; thunks: Thunk[]; listedAt: number };
14
15// The thunk the person is working on; the name when the pane knows it.
16export type CurrentThunk = { id: string; name?: string };
17
18export type NextStep = { label: string; prompt: string };
19
20declare module "claude-code" {
21 interface PluginState {
22 "thunk-skills": {
23 account: Account;
24 isNew: boolean;
25 next: NextStep | null;
26 isPaneOpen: boolean;
27 isChipHidden: boolean;
28 current: CurrentThunk | null;
29 };
30 }
31}
32