@candor-ui/react 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +187 -2
  3. package/dist/adapters/ag-ui.d.ts +75 -0
  4. package/dist/adapters/ag-ui.js +441 -0
  5. package/dist/adapters/ai-sdk.d.ts +22 -0
  6. package/dist/adapters/ai-sdk.js +129 -0
  7. package/dist/adapters/claude-agent-sdk.d.ts +80 -0
  8. package/dist/adapters/claude-agent-sdk.js +265 -0
  9. package/dist/adapters/claude-agent-server.d.ts +53 -0
  10. package/dist/adapters/claude-agent-server.js +104 -0
  11. package/dist/adapters/use-ag-ui.d.ts +55 -0
  12. package/dist/adapters/use-ag-ui.js +168 -0
  13. package/dist/adapters/use-candor-chat.d.ts +40 -0
  14. package/dist/adapters/use-candor-chat.js +56 -0
  15. package/dist/adapters/use-claude-agent.d.ts +48 -0
  16. package/dist/adapters/use-claude-agent.js +143 -0
  17. package/dist/ag-ui.d.ts +2 -0
  18. package/dist/ag-ui.js +2 -0
  19. package/dist/ai-sdk.d.ts +2 -0
  20. package/dist/ai-sdk.js +2 -0
  21. package/dist/blocks/copy-field.d.ts +13 -0
  22. package/dist/blocks/copy-field.js +15 -0
  23. package/dist/blocks/degraded-panel.d.ts +19 -0
  24. package/dist/blocks/degraded-panel.js +14 -0
  25. package/dist/blocks/locked.d.ts +26 -0
  26. package/dist/blocks/locked.js +36 -0
  27. package/dist/blocks/relative-time.d.ts +13 -0
  28. package/dist/blocks/relative-time.js +62 -0
  29. package/dist/blocks/status-dot.d.ts +16 -0
  30. package/dist/blocks/status-dot.js +25 -0
  31. package/dist/blocks/step-list.d.ts +23 -0
  32. package/dist/blocks/step-list.js +37 -0
  33. package/dist/blocks/streaming-text.d.ts +17 -0
  34. package/dist/blocks/streaming-text.js +14 -0
  35. package/dist/blocks/surface.d.ts +9 -0
  36. package/dist/blocks/surface.js +35 -0
  37. package/dist/blocks/terms.d.ts +24 -0
  38. package/dist/blocks/terms.js +17 -0
  39. package/dist/chat/agent-avatar.d.ts +34 -0
  40. package/dist/chat/agent-avatar.js +42 -0
  41. package/dist/chat/card-registry.d.ts +39 -0
  42. package/dist/chat/card-registry.js +49 -0
  43. package/dist/chat/citation.d.ts +29 -0
  44. package/dist/chat/citation.js +44 -0
  45. package/dist/chat/composer.d.ts +54 -0
  46. package/dist/chat/composer.js +168 -0
  47. package/dist/chat/context-meter.d.ts +14 -0
  48. package/dist/chat/context-meter.js +30 -0
  49. package/dist/chat/entity-chip.d.ts +37 -0
  50. package/dist/chat/entity-chip.js +52 -0
  51. package/dist/chat/message-actions.d.ts +21 -0
  52. package/dist/chat/message-actions.js +20 -0
  53. package/dist/chat/message-content.d.ts +25 -0
  54. package/dist/chat/message-content.js +122 -0
  55. package/dist/chat/message.d.ts +39 -0
  56. package/dist/chat/message.js +44 -0
  57. package/dist/chat/payload.d.ts +16 -0
  58. package/dist/chat/payload.js +18 -0
  59. package/dist/chat/prompt-suggestions.d.ts +20 -0
  60. package/dist/chat/prompt-suggestions.js +18 -0
  61. package/dist/chat/reasoning.d.ts +18 -0
  62. package/dist/chat/reasoning.js +31 -0
  63. package/dist/chat/thread.d.ts +18 -0
  64. package/dist/chat/thread.js +60 -0
  65. package/dist/chat/tool-call.d.ts +19 -0
  66. package/dist/chat/tool-call.js +33 -0
  67. package/dist/claude-server.d.ts +1 -0
  68. package/dist/claude-server.js +2 -0
  69. package/dist/claude.d.ts +2 -0
  70. package/dist/claude.js +2 -0
  71. package/dist/index.d.ts +40 -0
  72. package/dist/index.js +38 -0
  73. package/dist/lib/chat.d.ts +241 -0
  74. package/dist/lib/chat.js +1 -0
  75. package/dist/lib/know.d.ts +123 -0
  76. package/dist/lib/know.js +1 -0
  77. package/dist/lib/render-prop.d.ts +16 -0
  78. package/dist/lib/render-prop.js +7 -0
  79. package/dist/lib/safe-markdown.d.ts +12 -0
  80. package/dist/lib/safe-markdown.js +98 -0
  81. package/dist/lib/status.d.ts +21 -0
  82. package/dist/lib/status.js +26 -0
  83. package/dist/lib/use-copy.d.ts +10 -0
  84. package/dist/lib/use-copy.js +24 -0
  85. package/dist/lib/use-shortcut.d.ts +15 -0
  86. package/dist/lib/use-shortcut.js +62 -0
  87. package/dist/lib/utils.d.ts +2 -0
  88. package/dist/lib/utils.js +5 -0
  89. package/dist/styles.css +460 -0
  90. package/dist/ui/button.d.ts +12 -0
  91. package/dist/ui/button.js +41 -0
  92. package/dist/ui/collapsible.d.ts +6 -0
  93. package/dist/ui/collapsible.js +15 -0
  94. package/dist/ui/popover.d.ts +11 -0
  95. package/dist/ui/popover.js +29 -0
  96. package/dist/ui/skeleton.d.ts +2 -0
  97. package/dist/ui/skeleton.js +6 -0
  98. package/dist/ui/tooltip.d.ts +9 -0
  99. package/dist/ui/tooltip.js +24 -0
  100. package/package.json +90 -4
@@ -0,0 +1,265 @@
1
+ import { markdownBlocks, plainBlocks } from "../lib/safe-markdown.js";
2
+ /**
3
+ * Turns Claude Agent SDK events into Candor messages, waiting approvals and usage.
4
+ * Pure, so it can run on every render; useClaudeAgent calls it for you.
5
+ */
6
+ export function fromClaudeAgentEvents(events, options) {
7
+ const messages = [];
8
+ const approvals = new Map();
9
+ // Assigned inside `current`, which TypeScript can't follow, so it's typed by assertion.
10
+ let reply = null;
11
+ let sessionId;
12
+ let model;
13
+ let usage;
14
+ let lastCost = 0;
15
+ let notice;
16
+ const current = (id) => (reply ??= { id, parts: [], reasoning: [], tools: new Map(), live: new Map() });
17
+ const close = (last) => {
18
+ if (reply)
19
+ messages.push(render(reply, { ...options, model, last }));
20
+ reply = null;
21
+ };
22
+ for (const e of events) {
23
+ // Subagents report through their parent's tool call; the main thread is what the person reads.
24
+ if ("parent_tool_use_id" in e && e.parent_tool_use_id)
25
+ continue;
26
+ switch (e.type) {
27
+ case "candor_user":
28
+ close(false);
29
+ messages.push({ id: e.id, author: { role: "user" }, status: "complete", blocks: plainBlocks(e.text) });
30
+ notice = undefined;
31
+ break;
32
+ case "candor_approval": {
33
+ const { type: _, ...approval } = e;
34
+ approvals.set(e.id, approval);
35
+ const call = reply?.tools.get(e.toolUseId);
36
+ if (call)
37
+ call.status = "waiting";
38
+ break;
39
+ }
40
+ case "candor_approval_closed": {
41
+ const approval = approvals.get(e.id);
42
+ approvals.delete(e.id);
43
+ const call = approval && reply?.tools.get(approval.toolUseId);
44
+ if (call && call.status === "waiting") {
45
+ call.status = e.approved ? "running" : "cancelled";
46
+ if (!e.approved)
47
+ call.error = e.reason ? `Denied: ${e.reason}` : "Denied.";
48
+ }
49
+ break;
50
+ }
51
+ case "candor_card":
52
+ current(`card-${messages.length}`).parts.push({ kind: "card", card: e.card, props: e.props, summary: e.summary });
53
+ break;
54
+ case "candor_error":
55
+ // The first failure is the one that explains what happened.
56
+ current(`error-${messages.length}`).failure ??= e.message;
57
+ break;
58
+ case "system":
59
+ if (e.subtype === "init") {
60
+ sessionId = e.session_id;
61
+ model = e.model;
62
+ }
63
+ else if (e.subtype === "permission_denied") {
64
+ const call = reply?.tools.get(e.tool_use_id);
65
+ if (call) {
66
+ call.status = "cancelled";
67
+ call.error = `Not allowed: ${e.message}`;
68
+ }
69
+ }
70
+ else if (e.subtype === "api_retry") {
71
+ notice = `Claude’s API didn’t answer${e.error_status ? ` (${e.error_status})` : ""}; trying again, attempt ${e.attempt} of ${e.max_retries}.`;
72
+ }
73
+ break;
74
+ case "stream_event": {
75
+ const r = current(e.uuid);
76
+ const ev = e.event;
77
+ if (ev.type === "content_block_start" && (ev.content_block.type === "text" || ev.content_block.type === "thinking")) {
78
+ r.live.set(ev.index, { kind: ev.content_block.type, text: "" });
79
+ }
80
+ else if (ev.type === "content_block_delta") {
81
+ const live = r.live.get(ev.index);
82
+ if (live && ev.delta.type === "text_delta")
83
+ live.text += ev.delta.text;
84
+ else if (live && ev.delta.type === "thinking_delta")
85
+ live.text += ev.delta.thinking;
86
+ }
87
+ break;
88
+ }
89
+ case "assistant": {
90
+ const r = current(e.message.id);
91
+ // A complete message replaces whatever was streaming before it.
92
+ r.live.clear();
93
+ notice = undefined;
94
+ if (e.aborted)
95
+ r.aborted = true;
96
+ // An API error arrives as a synthetic message whose text is meant for a terminal user ("run /login"); say it plainly instead.
97
+ if (e.error) {
98
+ r.failure ??= assistantError(e.error);
99
+ break;
100
+ }
101
+ for (const block of e.message.content)
102
+ addBlock(r, block, options);
103
+ break;
104
+ }
105
+ case "user": {
106
+ if (!reply || typeof e.message.content === "string")
107
+ break;
108
+ for (const block of e.message.content) {
109
+ if (block.type !== "tool_result")
110
+ continue;
111
+ const call = reply.tools.get(block.tool_use_id);
112
+ if (!call || call.status === "cancelled")
113
+ continue;
114
+ const output = resultText(block.content);
115
+ call.output = output || undefined;
116
+ call.outputLanguage = "text";
117
+ call.status = block.is_error ? "failed" : "succeeded";
118
+ if (block.is_error)
119
+ call.error = firstLine(output) ?? "The tool failed and didn’t say why.";
120
+ }
121
+ break;
122
+ }
123
+ case "result": {
124
+ sessionId = e.session_id;
125
+ // The SDK's totals are cumulative for the session, so a reply's cost is the change since the last result.
126
+ const cost = e.total_cost_usd >= lastCost ? e.total_cost_usd - lastCost : e.total_cost_usd;
127
+ lastCost = e.total_cost_usd;
128
+ const tokens = Object.values(e.modelUsage ?? {});
129
+ usage = {
130
+ costUsd: e.total_cost_usd,
131
+ inputTokens: tokens.reduce((n, m) => n + m.inputTokens + m.cacheReadInputTokens + m.cacheCreationInputTokens, 0),
132
+ outputTokens: tokens.reduce((n, m) => n + m.outputTokens, 0),
133
+ turns: e.num_turns,
134
+ };
135
+ const r = current(e.uuid);
136
+ r.result = e.subtype === "success" && !e.is_error ? { ok: true, cost } : { ok: false, cost, error: resultError(e) };
137
+ notice = undefined;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ close(true);
143
+ return {
144
+ messages,
145
+ approvals: options.running ? [...approvals.values()] : [],
146
+ sessionId,
147
+ usage,
148
+ notice: options.running ? notice : undefined,
149
+ };
150
+ }
151
+ function addBlock(r, block, options) {
152
+ if (block.type === "text") {
153
+ if (block.text.trim())
154
+ r.parts.push({ kind: "text", text: block.text });
155
+ }
156
+ else if (block.type === "thinking") {
157
+ // With the SDK's default display, thinking arrives empty; there's nothing to show then.
158
+ if (block.thinking.trim())
159
+ r.reasoning.push(reasoningEntry(`${r.id}-r${r.reasoning.length}`, block.thinking, false));
160
+ }
161
+ else if (block.type === "tool_use" || block.type === "server_tool_use" || block.type === "mcp_tool_use") {
162
+ const call = { id: block.id, tool: block.name, title: options.toolTitle?.(block.name, block.input) ?? block.name, status: "running", input: json(block.input) };
163
+ r.tools.set(block.id, call);
164
+ r.parts.push({ kind: "tool", id: block.id });
165
+ }
166
+ else if ("tool_use_id" in block) {
167
+ // A server tool's result (web search, code execution) arrives in the assistant's own message.
168
+ const call = r.tools.get(block.tool_use_id);
169
+ if (call) {
170
+ const content = block.content;
171
+ const failed = !!content && typeof content === "object" && "error_code" in content;
172
+ call.status = failed ? "failed" : "succeeded";
173
+ call.output = content === undefined ? undefined : json(content);
174
+ call.outputLanguage = "json";
175
+ if (failed)
176
+ call.error = `The tool failed: ${String(content.error_code)}.`;
177
+ }
178
+ }
179
+ }
180
+ function render(r, o) {
181
+ const open = o.last && o.running;
182
+ const parts = [...r.parts];
183
+ const reasoning = [...r.reasoning];
184
+ if (open) {
185
+ for (const [index, live] of [...r.live].sort(([a], [b]) => a - b)) {
186
+ if (!live.text.trim())
187
+ continue;
188
+ if (live.kind === "text")
189
+ parts.push({ kind: "text", text: live.text });
190
+ else
191
+ reasoning.push(reasoningEntry(`${r.id}-live${index}`, live.text, true));
192
+ }
193
+ }
194
+ const ended = !open && !r.result?.ok;
195
+ const blocks = [];
196
+ let calls = [];
197
+ const flush = () => {
198
+ if (calls.length)
199
+ blocks.push({ kind: "tools", calls });
200
+ calls = [];
201
+ };
202
+ for (const p of parts) {
203
+ if (p.kind === "tool") {
204
+ const call = r.tools.get(p.id);
205
+ // A run that stopped early can't finish its tool calls.
206
+ calls.push(ended && (call.status === "running" || call.status === "waiting") ? { ...call, status: "cancelled", error: call.error ?? "Stopped before it finished." } : { ...call });
207
+ continue;
208
+ }
209
+ flush();
210
+ if (p.kind === "card")
211
+ blocks.push({ kind: "card", card: p.card, props: p.props, summary: p.summary });
212
+ else
213
+ blocks.push(...markdownBlocks(p.text));
214
+ }
215
+ flush();
216
+ const error = r.failure ?? r.result?.error;
217
+ if (error)
218
+ blocks.push({ kind: "degraded", reason: error });
219
+ return {
220
+ id: r.id,
221
+ author: { role: "agent", agent: o.agent },
222
+ status: open ? "streaming" : r.result?.ok ? "complete" : error ? "failed" : "interrupted",
223
+ blocks,
224
+ reasoning: reasoning.length ? reasoning : undefined,
225
+ attribution: r.result ? { model: o.model, cost: r.result.cost ? `$${r.result.cost.toFixed(r.result.cost < 0.01 ? 4 : 2)} (estimated)` : undefined } : undefined,
226
+ };
227
+ }
228
+ const assistantErrors = {
229
+ authentication_failed: "Claude couldn’t sign in. Check the server’s API key or login.",
230
+ billing_error: "Claude’s account has a billing problem.",
231
+ rate_limit: "Claude is rate-limited right now. Try again shortly.",
232
+ overloaded: "Claude is overloaded right now. Try again shortly.",
233
+ max_output_tokens: "The reply hit its length limit and was cut off.",
234
+ model_not_found: "The configured model isn’t available.",
235
+ };
236
+ const assistantError = (code) => assistantErrors[code] ?? `Claude’s API returned an error (${code}).`;
237
+ function resultError(e) {
238
+ if (e.subtype === "error_max_turns")
239
+ return "The agent reached its turn limit before finishing.";
240
+ if (e.subtype === "error_max_budget_usd")
241
+ return "The agent reached its spending limit before finishing.";
242
+ if (e.subtype === "success")
243
+ return "The agent finished with an error.";
244
+ return e.errors[0] ?? "The agent stopped because of an error.";
245
+ }
246
+ function resultText(content) {
247
+ if (typeof content === "string")
248
+ return content;
249
+ if (!Array.isArray(content))
250
+ return "";
251
+ return content.map((c) => (c.type === "text" ? (c.text ?? "") : c.type ? `[${c.type}]` : "")).join("\n");
252
+ }
253
+ const firstLine = (text) => text.trim().split("\n")[0] || undefined;
254
+ const json = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 2));
255
+ function reasoningEntry(id, text, streaming) {
256
+ const first = text.trim().split(/\n|(?<=[.!?])\s/)[0] ?? "";
257
+ return {
258
+ id,
259
+ title: first.length > 90 ? `${first.slice(0, 87)}…` : first,
260
+ body: text.trim(),
261
+ streaming,
262
+ basis: "believed",
263
+ assumption: "The model’s own reasoning; nothing checked it.",
264
+ };
265
+ }
@@ -0,0 +1,53 @@
1
+ import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { AgentEvent } from "./claude-agent-sdk.js";
3
+ export type RunClaudeAgent = (tools: {
4
+ /** Pass to query()'s `canUseTool`, so permission requests reach the person. */
5
+ canUseTool: CanUseTool;
6
+ /** Pass to query()'s `abortController`, so stopping in the browser stops the agent. */
7
+ abortController: AbortController;
8
+ /**
9
+ * Send a typed card to the browser, e.g. from a tool handler: `emit({ type: "candor_card", card: "diff", props, summary })`.
10
+ * It renders through the app's card registry, in the reply where it was sent.
11
+ */
12
+ emit: (event: AgentEvent) => void;
13
+ }) => Query;
14
+ export type StreamClaudeAgentOptions = {
15
+ /** The incoming request's signal (`request.signal`): the agent stops when the browser goes away. */
16
+ signal?: AbortSignal;
17
+ /** Who may answer this run's permission requests, e.g. the signed-in user's id. Checked by answerClaudeAgentApproval. */
18
+ owner?: string;
19
+ /** The sentence shown when the run fails. Defaults to a generic one, so server errors don't leak to the browser. */
20
+ describeError?: (error: unknown) => string;
21
+ /**
22
+ * Called with every SDK message just before it's sent to the browser, e.g. to record usage and cost from
23
+ * `assistant` and `result` messages. To stop the run from here, abort the run's abortController: the message
24
+ * in hand is still sent, and nothing after it.
25
+ */
26
+ onMessage?: (message: SDKMessage) => void | Promise<void>;
27
+ };
28
+ /**
29
+ * Run an agent and stream it to useClaudeAgent.
30
+ *
31
+ * // app/api/agent/route.ts
32
+ * export async function POST(request: Request) {
33
+ * const { prompt, sessionId } = await request.json()
34
+ * return streamClaudeAgent(
35
+ * ({ canUseTool, abortController }) =>
36
+ * query({ prompt, options: { canUseTool, abortController, resume: sessionId, includePartialMessages: true } }),
37
+ * { signal: request.signal, owner: userId, onMessage: (m) => m.type === "result" && recordCost(m.total_cost_usd) }
38
+ * )
39
+ * }
40
+ */
41
+ export declare function streamClaudeAgent(run: RunClaudeAgent, { signal, owner, describeError, onMessage }?: StreamClaudeAgentOptions): Response;
42
+ /**
43
+ * Answer a waiting permission request. Returns false when there's no such
44
+ * request (already answered, the run ended, or it ran in another process) or
45
+ * `owner` doesn't match the run's owner.
46
+ *
47
+ * // app/api/agent/approve/route.ts
48
+ * export async function POST(request: Request) {
49
+ * const { id, approved, reason } = await request.json()
50
+ * return Response.json({ ok: answerClaudeAgentApproval(id, approved, reason, userId) })
51
+ * }
52
+ */
53
+ export declare function answerClaudeAgentApproval(id: string, approved: boolean, reason?: string, owner?: string): boolean;
@@ -0,0 +1,104 @@
1
+ const waiting = new Map();
2
+ /**
3
+ * Run an agent and stream it to useClaudeAgent.
4
+ *
5
+ * // app/api/agent/route.ts
6
+ * export async function POST(request: Request) {
7
+ * const { prompt, sessionId } = await request.json()
8
+ * return streamClaudeAgent(
9
+ * ({ canUseTool, abortController }) =>
10
+ * query({ prompt, options: { canUseTool, abortController, resume: sessionId, includePartialMessages: true } }),
11
+ * { signal: request.signal, owner: userId, onMessage: (m) => m.type === "result" && recordCost(m.total_cost_usd) }
12
+ * )
13
+ * }
14
+ */
15
+ export function streamClaudeAgent(run, { signal, owner, describeError, onMessage } = {}) {
16
+ const encoder = new TextEncoder();
17
+ const abortController = new AbortController();
18
+ const mine = new Set();
19
+ let send = () => { };
20
+ const canUseTool = (tool, input, request) => new Promise((resolve) => {
21
+ const id = crypto.randomUUID();
22
+ const answer = (approved, reason, interrupt) => {
23
+ if (!waiting.delete(id))
24
+ return;
25
+ mine.delete(id);
26
+ send({ type: "candor_approval_closed", id, approved, reason });
27
+ resolve(approved
28
+ ? { behavior: "allow", updatedInput: input }
29
+ : { behavior: "deny", message: reason ? `The user declined: ${reason}` : "The user declined this action.", interrupt });
30
+ };
31
+ waiting.set(id, { owner, answer });
32
+ mine.add(id);
33
+ request.signal.addEventListener("abort", () => answer(false, "The run was stopped.", true), { once: true });
34
+ send({
35
+ type: "candor_approval",
36
+ id,
37
+ toolUseId: request.toolUseID,
38
+ tool,
39
+ input,
40
+ title: request.title,
41
+ description: request.description,
42
+ reason: request.decisionReason,
43
+ blockedPath: request.blockedPath,
44
+ defaultToNo: request.defaultToNo,
45
+ });
46
+ });
47
+ const body = new ReadableStream({
48
+ async start(controller) {
49
+ let open = true;
50
+ send = (event) => {
51
+ if (open)
52
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
53
+ };
54
+ const stop = () => abortController.abort();
55
+ signal?.addEventListener("abort", stop, { once: true });
56
+ try {
57
+ for await (const message of run({ canUseTool, abortController, emit: (event) => send(event) })) {
58
+ // Awaited, so bookkeeping such as recording spend finishes before the next message is handled.
59
+ await onMessage?.(message);
60
+ send(message);
61
+ // Stopped (the browser went away, or onMessage or a tool aborted the run): nothing more is sent.
62
+ if (abortController.signal.aborted)
63
+ break;
64
+ }
65
+ }
66
+ catch (error) {
67
+ if (!abortController.signal.aborted) {
68
+ console.error("Claude agent run failed:", error);
69
+ send({ type: "candor_error", message: describeError?.(error) ?? "The agent stopped because of an error on the server." });
70
+ }
71
+ }
72
+ finally {
73
+ // Nobody can answer once the run is over.
74
+ for (const id of [...mine])
75
+ waiting.get(id)?.answer(false, "The run ended.");
76
+ open = false;
77
+ signal?.removeEventListener("abort", stop);
78
+ controller.close();
79
+ }
80
+ },
81
+ cancel() {
82
+ abortController.abort();
83
+ },
84
+ });
85
+ return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" } });
86
+ }
87
+ /**
88
+ * Answer a waiting permission request. Returns false when there's no such
89
+ * request (already answered, the run ended, or it ran in another process) or
90
+ * `owner` doesn't match the run's owner.
91
+ *
92
+ * // app/api/agent/approve/route.ts
93
+ * export async function POST(request: Request) {
94
+ * const { id, approved, reason } = await request.json()
95
+ * return Response.json({ ok: answerClaudeAgentApproval(id, approved, reason, userId) })
96
+ * }
97
+ */
98
+ export function answerClaudeAgentApproval(id, approved, reason, owner) {
99
+ const request = waiting.get(id);
100
+ if (!request || (request.owner !== undefined && request.owner !== owner))
101
+ return false;
102
+ request.answer(approved, reason);
103
+ return true;
104
+ }
@@ -0,0 +1,55 @@
1
+ import * as React from "react";
2
+ import { type RunAgentParameters } from "@ag-ui/client";
3
+ import type { EntityRef } from "../lib/chat.js";
4
+ import { type FromAgUiOptions } from "./ag-ui.js";
5
+ export type UseAgUiOptions = Omit<FromAgUiOptions, "running" | "now"> & {
6
+ /** Your AG-UI endpoint (LangGraph, Mastra, CrewAI, Google ADK, Pydantic AI…). It receives a RunAgentInput and streams events back. */
7
+ url: string;
8
+ headers?: Record<string, string>;
9
+ /** Replaces the global fetch, e.g. to add auth or to play a script in tests and demos. */
10
+ fetch?: typeof fetch;
11
+ /** Continue an existing thread instead of starting a new one. */
12
+ threadId?: string;
13
+ /** Sent with every run as `forwardedProps`, next to the `references` the person mentioned. */
14
+ forwardedProps?: Record<string, unknown>;
15
+ /** Tools and context for every run, as AG-UI defines them. Candor doesn't run frontend tools for you. */
16
+ tools?: RunAgentParameters["tools"];
17
+ context?: RunAgentParameters["context"];
18
+ };
19
+ /**
20
+ * Wires an AG-UI agent to Candor: messages for <Thread>/<Message>, props for
21
+ * <Composer>, and the interrupts waiting on a person. The same shape as
22
+ * useClaudeAgent and useCandorChat, so screens work with any of them.
23
+ *
24
+ * const { messages, composer, approvals, respond } = useAgUi({ url: "/api/agent", agent })
25
+ * <Thread footer={<Composer {...composer} />}>{messages.map((m) => <Message key={m.id} message={m} />)}</Thread>
26
+ *
27
+ * A run that ends on an interrupt shows it in `approvals`. `respond` records the
28
+ * answer, and once every open interrupt has one, starts the next run with
29
+ * `resume`: approved → `{ status: "resolved", payload: { approved: true } }`,
30
+ * denied → `{ status: "cancelled", payload: { approved: false, reason } }`.
31
+ * Mentions are sent as structured `forwardedProps.references`, not pasted into the text.
32
+ */
33
+ export declare function useAgUi(options: UseAgUiOptions): {
34
+ messages: import("../index.js").MessageData[];
35
+ approvals: import("./ag-ui.js").AgUiApproval[];
36
+ respond: (interruptId: string, approved: boolean, reason?: string) => Promise<void>;
37
+ /** Tokens the runs reported, summed. AG-UI reports no cost. */
38
+ usage: import("./ag-ui.js").AgUiUsage | undefined;
39
+ /** Something the person should know that isn't part of a reply, such as an expired request. */
40
+ notice: string | undefined;
41
+ /** The request failed; say so, and let the person send again. */
42
+ error: string | undefined;
43
+ threadId: string | undefined;
44
+ /** Forget the conversation and start a new thread. */
45
+ reset: () => void;
46
+ composer: {
47
+ value: string;
48
+ onChange: React.Dispatch<React.SetStateAction<string>>;
49
+ state: "streaming" | "ready" | "sending";
50
+ references: EntityRef[];
51
+ onReferencesChange: React.Dispatch<React.SetStateAction<EntityRef[]>>;
52
+ onStop: () => void | undefined;
53
+ onSubmit: (text: string, refs: EntityRef[]) => void;
54
+ };
55
+ };
@@ -0,0 +1,168 @@
1
+ import * as React from "react";
2
+ import { HttpAgent, isInterruptExpired } from "@ag-ui/client";
3
+ import { fromAgUiEvents } from "./ag-ui.js";
4
+ /**
5
+ * Wires an AG-UI agent to Candor: messages for <Thread>/<Message>, props for
6
+ * <Composer>, and the interrupts waiting on a person. The same shape as
7
+ * useClaudeAgent and useCandorChat, so screens work with any of them.
8
+ *
9
+ * const { messages, composer, approvals, respond } = useAgUi({ url: "/api/agent", agent })
10
+ * <Thread footer={<Composer {...composer} />}>{messages.map((m) => <Message key={m.id} message={m} />)}</Thread>
11
+ *
12
+ * A run that ends on an interrupt shows it in `approvals`. `respond` records the
13
+ * answer, and once every open interrupt has one, starts the next run with
14
+ * `resume`: approved → `{ status: "resolved", payload: { approved: true } }`,
15
+ * denied → `{ status: "cancelled", payload: { approved: false, reason } }`.
16
+ * Mentions are sent as structured `forwardedProps.references`, not pasted into the text.
17
+ */
18
+ export function useAgUi(options) {
19
+ const { url, headers, agent, toolTitle, fetch: fetcher, threadId, forwardedProps, tools, context } = options;
20
+ const [events, setEvents] = React.useState([]);
21
+ const [running, setRunning] = React.useState(false);
22
+ const [error, setError] = React.useState();
23
+ const [value, setValue] = React.useState("");
24
+ const [references, setReferences] = React.useState([]);
25
+ // Interrupts expire on the clock, not on an event; `now` moves when one does.
26
+ const [now, setNow] = React.useState(() => Date.now());
27
+ const run = React.useRef(null);
28
+ const client = React.useRef(null);
29
+ const answers = React.useRef(new Map());
30
+ const state = React.useMemo(() => fromAgUiEvents(events, { agent, toolTitle, running, now: new Date(now) }), [events, agent, toolTitle, running, now]);
31
+ const nextExpiry = state.approvals.reduce((soonest, a) => {
32
+ const at = a.expiresAt ? Date.parse(a.expiresAt) : NaN;
33
+ return Number.isNaN(at) ? soonest : Math.min(soonest ?? at, at);
34
+ }, undefined);
35
+ React.useEffect(() => {
36
+ if (nextExpiry === undefined)
37
+ return;
38
+ const timer = setTimeout(() => setNow(Date.now()), Math.max(0, nextExpiry - Date.now()) + 50);
39
+ return () => clearTimeout(timer);
40
+ }, [nextExpiry]);
41
+ // Stop the agent if the component goes away mid-run.
42
+ React.useEffect(() => () => run.current?.abort(), []);
43
+ const getClient = React.useCallback(() => {
44
+ if (!client.current)
45
+ client.current = new HttpAgent({ url, headers, fetch: fetcher, threadId });
46
+ const http = client.current;
47
+ // The latest options win, without losing the thread.
48
+ http.url = url;
49
+ http.headers = { ...headers };
50
+ if (fetcher)
51
+ http.fetch = fetcher;
52
+ return http;
53
+ }, [url, headers, fetcher, threadId]);
54
+ const start = React.useCallback(async (http, params, before) => {
55
+ const controller = new AbortController();
56
+ run.current = controller;
57
+ setError(undefined);
58
+ setRunning(true);
59
+ setEvents((e) => [...e, ...before]);
60
+ // Streamed text arrives a few characters at a time; apply it once per frame.
61
+ const pending = { events: [], frame: 0 };
62
+ const flush = () => {
63
+ pending.frame = 0;
64
+ const batch = pending.events.splice(0);
65
+ if (batch.length)
66
+ setEvents((e) => [...e, ...batch]);
67
+ };
68
+ try {
69
+ await http.runAgent({ resume: params.resume, tools, context, forwardedProps: { ...forwardedProps, references: params.references ?? [] }, abortController: controller }, {
70
+ onEvent: ({ event }) => {
71
+ pending.events.push(event);
72
+ pending.frame ||= requestAnimationFrame(flush);
73
+ },
74
+ });
75
+ }
76
+ catch (err) {
77
+ if (!controller.signal.aborted) {
78
+ const status = err.status;
79
+ const message = typeof status === "number" ? `The agent request failed (${status}).` : "The agent request failed, or its stream broke off.";
80
+ pending.events.push({ type: "candor_error", message });
81
+ setError(message);
82
+ }
83
+ }
84
+ finally {
85
+ cancelAnimationFrame(pending.frame);
86
+ flush();
87
+ run.current = null;
88
+ setRunning(false);
89
+ }
90
+ }, [tools, context, forwardedProps]);
91
+ const send = React.useCallback((text, refs) => {
92
+ if (run.current)
93
+ return;
94
+ const http = getClient();
95
+ // A new message moves on from any interrupt still open: the agent is told each was cancelled.
96
+ const reason = "The person sent a new message instead.";
97
+ const resume = http.pendingInterrupts.map((i) => ({ interruptId: i.id, status: "cancelled", payload: { approved: false, reason } }));
98
+ const closed = http.pendingInterrupts.map((i) => ({ type: "candor_answer", interruptId: i.id, approved: false, reason }));
99
+ answers.current.clear();
100
+ const id = crypto.randomUUID();
101
+ http.addMessage({ id, role: "user", content: text });
102
+ void start(http, { resume: resume.length ? resume : undefined, references: refs }, [...closed, { type: "candor_user", id, text, references: refs.length ? refs : undefined }]);
103
+ }, [getClient, start]);
104
+ const respond = React.useCallback(async (interruptId, approved, reason) => {
105
+ const http = client.current;
106
+ if (run.current || !http)
107
+ return;
108
+ const open = http.pendingInterrupts;
109
+ if (!open.some((i) => i.id === interruptId)) {
110
+ setError("That request is no longer open.");
111
+ return;
112
+ }
113
+ answers.current.set(interruptId, { approved, reason });
114
+ const answer = { type: "candor_answer", interruptId, approved, reason };
115
+ // The agent expects one answer for every open interrupt; wait for the rest.
116
+ if (open.some((i) => !answers.current.has(i.id) && !isInterruptExpired(i))) {
117
+ setEvents((e) => [...e, answer]);
118
+ return;
119
+ }
120
+ const resume = open.map((i) => resumeEntry(i, answers.current.get(i.id)));
121
+ answers.current.clear();
122
+ await start(http, { resume }, [answer]);
123
+ }, [start]);
124
+ const stop = React.useCallback(() => run.current?.abort(), []);
125
+ const composerState = running ? (state.messages.at(-1)?.author.role === "agent" ? "streaming" : "sending") : "ready";
126
+ return {
127
+ messages: state.messages,
128
+ approvals: state.approvals,
129
+ respond,
130
+ /** Tokens the runs reported, summed. AG-UI reports no cost. */
131
+ usage: state.usage,
132
+ /** Something the person should know that isn't part of a reply, such as an expired request. */
133
+ notice: state.notice,
134
+ /** The request failed; say so, and let the person send again. */
135
+ error,
136
+ threadId: state.threadId,
137
+ /** Forget the conversation and start a new thread. */
138
+ reset: () => {
139
+ run.current?.abort();
140
+ client.current = null;
141
+ answers.current.clear();
142
+ setEvents([]);
143
+ setError(undefined);
144
+ },
145
+ composer: {
146
+ value,
147
+ onChange: setValue,
148
+ state: composerState,
149
+ references,
150
+ onReferencesChange: setReferences,
151
+ onStop: stop,
152
+ onSubmit: (text, refs) => {
153
+ if (!text.trim() || running)
154
+ return;
155
+ send(text, refs);
156
+ setValue("");
157
+ setReferences([]);
158
+ },
159
+ },
160
+ };
161
+ }
162
+ /** An expired interrupt can only be cancelled; the client refuses anything else. */
163
+ function resumeEntry(i, answer) {
164
+ if (answer?.approved && !isInterruptExpired(i))
165
+ return { interruptId: i.id, status: "resolved", payload: { approved: true } };
166
+ const reason = answer ? answer.reason : "Nobody answered before it expired.";
167
+ return { interruptId: i.id, status: "cancelled", payload: { approved: false, ...(reason ? { reason } : {}) } };
168
+ }