Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ npm install
npm run dev
```

> **Cloudflare authentication is required to run locally.** This template uses
> Workers AI with `"ai": { "remote": true }` in `wrangler.jsonc`, and Workers AI
> has no local simulator — so `npm run dev` opens a remote proxy session against
> Cloudflare and needs you to be authenticated. Either run `wrangler login` once
> in an interactive terminal, or set a `CLOUDFLARE_API_TOKEN` environment
> variable (e.g. in a `.env` file). No third-party (OpenAI/Anthropic) key is
> needed, but a Cloudflare login is.

Open [http://localhost:5173](http://localhost:5173) to see your agent in action.

Try these prompts to see the different features:
Expand Down Expand Up @@ -122,7 +130,7 @@ async executeTask(description: string, task: Schedule<string>) {

### Remove scheduling

If you don't need scheduling, remove `scheduleTask`, `getScheduledTasks`, and `cancelScheduledTask` from the tools object, the `executeTask` method, and the schedule-related imports (`getSchedulePrompt`, `scheduleSchema`, `Schedule`, `generateId`).
If you don't need scheduling, remove `scheduleTask`, `getScheduledTasks`, and `cancelScheduledTask` from the tools object, the `executeTask` method, and the schedule-related imports (`getSchedulePrompt`, `scheduleSchema`, `Schedule`).

### Add state beyond chat messages

Expand Down
14,708 changes: 14,702 additions & 6 deletions env.d.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="workers">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down
1,356 changes: 858 additions & 498 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@
"dev": "vite dev",
"start": "vite dev",
"deploy": "vite build && wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false",
"types": "wrangler types env.d.ts",
"format": "oxfmt --write .",
"lint": "oxlint src/",
"check": "oxfmt --check . && oxlint src/ && tsc"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.9.1",
"@cloudflare/ai-chat": "^0.9.3",
"@cloudflare/kumo": "^2.6.0",
"@phosphor-icons/react": "^2.1.10",
"@streamdown/code": "^1.1.1",
"agents": "^0.17.1",
"agents": "^0.17.4",
"ai": "^6.0.197",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand All @@ -34,8 +34,7 @@
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "1.42.3",
"@cloudflare/workers-types": "^4.20260628.1",
"@cloudflare/vite-plugin": "^1.46.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^26.0.1",
Expand All @@ -47,12 +46,12 @@
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"wrangler": "4.105.0"
"wrangler": "^4.113.0"
},
"allowScripts": {
"core-js-pure@3.49.0": true,
"esbuild@0.28.1": true,
"workerd@1.20260625.1": true,
"workerd@1.20260721.1": true,
"sharp@0.34.5": true,
"fsevents@2.3.3": true
}
Expand Down
170 changes: 99 additions & 71 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Button,
Empty,
InputArea,
PoweredByCloudflare,
Surface,
Switch,
Text
Expand Down Expand Up @@ -95,6 +96,23 @@ function ThemeToggle() {

// ── Tool rendering ────────────────────────────────────────────────────

function ToolIO({ label, value }: { label: string; value: unknown }) {
if (value === undefined || value === null) return null;
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
if (!text) return null;
return (
<div className="mt-1">
<Text size="xs" variant="secondary" bold>
{label}
</Text>
<pre className="mt-0.5 font-mono text-xs text-kumo-subtle whitespace-pre-wrap overflow-auto max-h-64">
{text}
</pre>
</div>
);
}

function ToolPartView({
part,
addToolApprovalResponse
Expand All @@ -120,11 +138,8 @@ function ToolPartView({
</Text>
<Badge variant="secondary">Done</Badge>
</div>
<div className="font-mono">
<Text size="xs" variant="secondary">
{JSON.stringify(part.output, null, 2)}
</Text>
</div>
<ToolIO label="Input" value={part.input} />
<ToolIO label="Output" value={part.output} />
</Surface>
</div>
);
Expand Down Expand Up @@ -199,6 +214,29 @@ function ToolPartView({
);
}

// Errored
if (part.state === "output-error") {
const errorText = part.errorText;
return (
<div className="flex justify-start">
<Surface className="max-w-[85%] px-4 py-2.5 rounded-xl ring-2 ring-kumo-danger">
<div className="flex items-center gap-2 mb-1">
<XCircleIcon size={14} className="text-kumo-danger" />
<Text size="xs" variant="secondary" bold>
{toolName}
</Text>
<Badge variant="destructive">Error</Badge>
</div>
<div className="font-mono">
<Text size="xs" variant="secondary">
{errorText || "Tool call failed"}
</Text>
</div>
</Surface>
</div>
);
}

// Executing
if (part.state === "input-available" || part.state === "input-streaming") {
return (
Expand All @@ -210,6 +248,7 @@ function ToolPartView({
Running {toolName}...
</Text>
</div>
<ToolIO label="Input" value={part.input} />
</Surface>
</div>
);
Expand Down Expand Up @@ -322,13 +361,10 @@ function Chat() {
} = useAgentChat({
agent,
experimental_throttle: 100,
onToolCall: async (event) => {
if (
"addToolOutput" in event &&
event.toolCall.toolName === "getUserTimezone"
) {
event.addToolOutput({
toolCallId: event.toolCall.toolCallId,
onToolCall: async ({ toolCall, addToolOutput }) => {
if (toolCall.toolName === "getUserTimezone") {
addToolOutput({
toolCallId: toolCall.toolCallId,
output: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
localTime: new Date().toLocaleTimeString()
Expand Down Expand Up @@ -706,31 +742,25 @@ function Chat() {
</pre>
)}

{/* Tool parts */}
{message.parts.filter(isToolUIPart).map((part) => (
<ToolPartView
key={part.toolCallId}
part={part}
addToolApprovalResponse={addToolApprovalResponse}
/>
))}

{/* Reasoning parts */}
{message.parts
.filter(
(part) =>
part.type === "reasoning" &&
(part as { text?: string }).text?.trim()
)
.map((part, i) => {
const reasoning = part as {
type: "reasoning";
text: string;
state?: "streaming" | "done";
};
const isDone = reasoning.state === "done" || !isStreaming;
{/* Render parts in chronological (array) order */}
{message.parts.map((part, i) => {
const key = `${message.id}-${i}`;

if (isToolUIPart(part)) {
return (
<ToolPartView
key={key}
part={part}
addToolApprovalResponse={addToolApprovalResponse}
/>
);
}

if (part.type === "reasoning") {
if (!part.text.trim()) return null;
const isDone = part.state === "done" || !isStreaming;
return (
<div key={i} className="flex justify-start">
<div key={key} className="flex justify-start">
<details className="max-w-[85%] w-full" open={!isDone}>
<summary className="flex items-center gap-2 cursor-pointer px-3 py-2 rounded-lg bg-purple-500/10 border border-purple-500/20 text-sm select-none">
<BrainIcon size={14} className="text-purple-400" />
Expand All @@ -752,67 +782,62 @@ function Chat() {
/>
</summary>
<pre className="mt-2 px-3 py-2 rounded-lg bg-kumo-control text-xs text-kumo-default whitespace-pre-wrap overflow-auto max-h-64">
{reasoning.text}
{part.text}
</pre>
</details>
</div>
);
})}

{/* Image parts */}
{message.parts
.filter(
(part): part is Extract<typeof part, { type: "file" }> =>
part.type === "file" &&
(part as { mediaType?: string }).mediaType?.startsWith(
"image/"
) === true
)
.map((part, i) => (
<div
key={`file-${i}`}
className={`flex ${isUser ? "justify-end" : "justify-start"}`}
>
<img
src={part.url}
alt="Attachment"
className="max-h-64 rounded-xl border border-kumo-line object-contain"
/>
</div>
))}
}

{/* Text parts */}
{message.parts
.filter((part) => part.type === "text")
.map((part, i) => {
const text = (part as { type: "text"; text: string }).text;
if (!text) return null;
if (
part.type === "file" &&
part.mediaType.startsWith("image/")
) {
return (
<div
key={key}
className={`flex ${isUser ? "justify-end" : "justify-start"}`}
>
<img
src={part.url}
alt="Attachment"
className="max-h-64 rounded-xl border border-kumo-line object-contain"
/>
</div>
);
}

if (part.type === "text") {
if (!part.text) return null;

if (isUser) {
return (
<div key={i} className="flex justify-end">
<div key={key} className="flex justify-end">
<div className="max-w-[85%] px-4 py-2.5 rounded-2xl rounded-br-md bg-kumo-contrast text-kumo-inverse leading-relaxed">
{text}
{part.text}
</div>
</div>
);
}

return (
<div key={i} className="flex justify-start">
<div key={key} className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-bl-md bg-kumo-base text-kumo-default leading-relaxed">
<Streamdown
className="sd-theme rounded-2xl rounded-bl-md p-3"
plugins={{ code }}
controls={false}
isAnimating={isLastAssistant && isStreaming}
>
{text}
{part.text}
</Streamdown>
</div>
</div>
);
})}
}

return null;
})}
</div>
);
})}
Expand Down Expand Up @@ -929,6 +954,9 @@ function Chat() {
)}
</div>
</form>
<div className="flex justify-center pb-3">
<PoweredByCloudflare href="https://developers.cloudflare.com/agents/" />
</div>
</div>
</div>
);
Expand Down
12 changes: 8 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { z } from "zod";
export class ChatAgent extends AIChatAgent<Env> {
maxPersistedMessages = 100;
chatRecovery = true;
// Wait for MCP connections to be re-established after hibernation before
// processing a message, so MCP tools aren't intermittently missing.
waitForMcpConnections = true;

onStart() {
// Configure OAuth popup behavior for MCP servers that require authentication
Expand Down Expand Up @@ -48,18 +51,19 @@ export class ChatAgent extends AIChatAgent<Env> {
const workersai = createWorkersAI({ binding: this.env.AI });

const result = streamText({
model: workersai("@cf/moonshotai/kimi-k2.6", {
model: workersai("@cf/moonshotai/kimi-k2.7-code", {
sessionAffinity: this.sessionAffinity
}),
system: `You are a helpful assistant that can understand images. You can check the weather, get the user's timezone, run calculations, and schedule tasks. When users share images, describe what you see and answer questions about them.

${getSchedulePrompt({ date: new Date() })}

If the user asks to schedule a task, use the schedule tool to schedule the task.`,
// Prune old tool calls to save tokens on long conversations
// Prune old tool calls and reasoning to save tokens on long conversations
messages: pruneMessages({
messages: await convertToModelMessages(this.messages),
toolCalls: "before-last-2-messages"
toolCalls: "before-last-2-messages",
reasoning: "before-last-message"
}),
tools: {
// MCP tools from connected servers
Expand Down Expand Up @@ -175,7 +179,7 @@ If the user asks to schedule a task, use the schedule tool to schedule the task.
}
})
},
stopWhen: stepCountIs(5),
stopWhen: stepCountIs(20),
abortSignal: options?.abortSignal
});

Expand Down
4 changes: 3 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"extends": "agents/tsconfig",
"compilerOptions": {
// add your overrides here
// Runtime types come from `wrangler types` (env.d.ts), not the
// @cloudflare/workers-types package.
"types": ["node", "vite/client"]
}
}
2 changes: 1 addition & 1 deletion wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "agent-starter",
"main": "src/server.ts",
"compatibility_date": "2026-03-02",
"compatibility_date": "2026-06-11",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI", "remote": true },
"assets": {
Expand Down
Loading