Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions .changeset/dynamic-think-workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudflare/think": minor
---

Add dynamic workflow support via `@cloudflare/think/dynamic-workflows`.

New API:

- `Think.runDynamicWorkflow(workflowName, code, params?, options?)` — stores generated TypeScript and starts a Dynamic Workflow instance
- `Think.getWorkflowCode(wfId)` — RPC method used by the loader to retrieve stored code
- `DynamicThinkWorkflow` — export from `@cloudflare/think/dynamic-workflows`, register as `class_name` in your `[[workflows]]` wrangler binding

Generated code extending `ThinkWorkflow` is bundled at runtime with `@cloudflare/worker-bundler` and executed as a Dynamic Worker with full durable execution (`step.prompt()`, `step.do()`, `step.sleep()`, `step.waitForEvent()`).

New dependencies: `@cloudflare/dynamic-workflows` (runtime), `@cloudflare/worker-bundler` (optional peer dep).
31 changes: 31 additions & 0 deletions examples/dynamic-think-workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Dynamic Think Workflows

Run generated `ThinkWorkflow` code at runtime as Dynamic Workers with full durable execution (`step.prompt()`, `step.do()`, `step.sleep()`, `step.waitForEvent()`).

## How it works

1. `MyAgent.runDynamicWorkflow()` stores generated TypeScript source in SQLite and starts a Workflow instance
2. The `DynamicThinkWorkflow` entrypoint (registered in `wrangler.jsonc`) loads the code from the agent, bundles it with its npm dependencies via `@cloudflare/worker-bundler`, and loads it as a Dynamic Worker
3. The Workflows engine dispatches `run(event, step)` to the Dynamic Worker — `step.prompt()` works natively

## Run it

```bash
pnpm install
pnpm run dev
```

Then start a dynamic workflow:

```bash
curl -X POST http://localhost:8787/run \
-H "Content-Type: application/json" \
-d '{"topic": "The future of serverless computing"}'
```

## Key concepts

- **Generated code** is plain TypeScript extending `ThinkWorkflow` — not a DSL or interpreter
- **`@cloudflare/dynamic-workflows`** handles routing between the Worker Loader and the Workflows engine
- **`@cloudflare/worker-bundler`** resolves npm dependencies (`@cloudflare/think`, `zod`) at runtime
- The generated workflow has full access to `step.prompt()`, `this.agent`, and all ThinkWorkflow features
24 changes: 24 additions & 0 deletions examples/dynamic-think-workflows/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@cloudflare/agents-dynamic-think-workflows-example",
"description": "Dynamic ThinkWorkflow example — run generated workflow code at runtime",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false"
},
"dependencies": {
"@cloudflare/think": "*",
"agents": "*",
"ai": "^6.0.202",
"workers-ai-provider": "^3.2.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260612.1",
"typescript": "^6.0.3",
"wrangler": "^4.100.0"
}
}
115 changes: 115 additions & 0 deletions examples/dynamic-think-workflows/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { getAgentByName, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { Think } from "@cloudflare/think";
import { DynamicThinkWorkflow } from "@cloudflare/think/dynamic-workflows";

export { DynamicThinkWorkflow };

type Env = {
AI: Ai;
LOADER: WorkerLoader;
MyAgent: DurableObjectNamespace<MyAgent>;
DYNAMIC_THINK_WF: Workflow;
};

/**
* A simple Think agent that can launch dynamic workflows.
*/
export class MyAgent extends Think<Env> {
getModel() {
return createWorkersAI({ binding: this.env.AI })(
"@cf/moonshotai/kimi-k2.7-code"
);
}

getSystemPrompt() {
return "You are a helpful assistant.";
}

/**
* Generate and run a dynamic ThinkWorkflow.
* In a real app, this code could be LLM-generated, loaded from a DB,
* or authored by users at runtime.
*/
async startDynamicWorkflow(topic: string): Promise<{ workflowId: string }> {
const code = generateWorkflowCode();
const workflowId = await this.runDynamicWorkflow("DYNAMIC_THINK_WF", code, {
topic
});
return { workflowId };
}
}

/**
* Generate a ThinkWorkflow class as a TypeScript string.
*
* The generated code extends ThinkWorkflow and uses step.prompt() for
* durable LLM calls. It's bundled at runtime by worker-bundler and
* executed as a Dynamic Worker.
*/
function generateWorkflowCode(): string {
return `
import { ThinkWorkflow } from "@cloudflare/think/workflows";
import type { ThinkWorkflowStep } from "@cloudflare/think/workflows";
import type { AgentWorkflowEvent } from "agents/workflows";
import { z } from "zod";

type Params = { topic: string };

const summarySchema = z.object({
title: z.string(),
summary: z.string(),
keyPoints: z.array(z.string())
});

export default class GeneratedWorkflow extends ThinkWorkflow {
async run(
event: AgentWorkflowEvent<Params>,
step: ThinkWorkflowStep
): Promise<void> {
const result = await step.prompt("analyze", {
prompt: "Write a brief analysis about: " + event.payload.topic,
output: summarySchema,
timeout: "3 minutes"
});

await step.do("save-result", async () => {
console.log("Analysis complete:", JSON.stringify(result, null, 2));
});
}
}
`.trim();
}

async function getDefaultAgent(env: Env) {
return getAgentByName(env.MyAgent, "default");
}

export default {
async fetch(request: Request, env: Env) {
const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) return agentResponse;

const url = new URL(request.url);

if (request.method === "POST" && url.pathname === "/run") {
const body = (await request.json()) as { topic?: unknown };
if (typeof body.topic !== "string" || body.topic.trim() === "") {
return Response.json(
{ error: "Expected JSON body with 'topic' field" },
{ status: 400 }
);
}
const agent = await getDefaultAgent(env);
const result = await agent.startDynamicWorkflow(body.topic);
return Response.json(result);
}

return Response.json(
{
routes: ["POST /run { topic: string } — start a dynamic ThinkWorkflow"]
},
{ status: 404 }
);
}
} satisfies ExportedHandler<Env>;
3 changes: 3 additions & 0 deletions examples/dynamic-think-workflows/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "agents/tsconfig"
}
33 changes: 33 additions & 0 deletions examples/dynamic-think-workflows/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "dynamic-think-workflows-example",
"main": "src/index.ts",
"compatibility_date": "2026-06-11",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI", "remote": true },
"worker_loaders": [{ "binding": "LOADER" }],
"durable_objects": {
"bindings": [
{
"name": "MyAgent",
"class_name": "MyAgent"
}
]
},
"workflows": [
{
"name": "dynamic-think",
"binding": "DYNAMIC_THINK_WF",
"class_name": "DynamicThinkWorkflow"
}
],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent"]
}
],
"observability": {
"enabled": true
}
}
40 changes: 25 additions & 15 deletions packages/think/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {
"@cloudflare/codemode": ">=0.4.0",
"@cloudflare/dynamic-workflows": ">=0.1.1",
"@cloudflare/shell": ">=0.4.0",
"aywson": "^0.0.16",
"chat": "^4.30.0",
Expand All @@ -33,13 +34,33 @@
"smol-toml": "^1.6.1",
"yargs": "^18.0.0"
},
"peerDependencies": {
"@chat-adapter/telegram": "^4.29.0",
"@cloudflare/worker-bundler": ">=0.2.1",
"agents": ">=0.16.0 <1.0.0",
"ai": "^6.0.182",
"vite": ">=6 <9",
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"@chat-adapter/telegram": {
"optional": true
},
"@cloudflare/worker-bundler": {
"optional": true
},
"vite": {
"optional": true
}
},
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.83",
"@ai-sdk/openai": "^3.0.70",
"@ai-sdk/react": "^3.0.204",
"@chat-adapter/telegram": "^4.30.0",
"@cloudflare/ai-chat": "workspace:*",
"@cloudflare/kumo": "^2.5.2",
"@cloudflare/worker-bundler": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@streamdown/code": "^1.1.1",
"@tailwindcss/vite": "^4",
Expand All @@ -58,21 +79,6 @@
"vite": "^8.0.16",
"zod": "^4.4.3"
},
"peerDependencies": {
"@chat-adapter/telegram": "^4.29.0",
"agents": ">=0.16.0 <1.0.0",
"ai": "^6.0.182",
"vite": ">=6 <9",
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"@chat-adapter/telegram": {
"optional": true
},
"vite": {
"optional": true
}
},
"exports": {
".": {
"types": "./dist/think.d.ts",
Expand All @@ -86,6 +92,10 @@
"types": "./dist/workflows.d.ts",
"import": "./dist/workflows.js"
},
"./dynamic-workflows": {
"types": "./dist/dynamic-workflows/index.d.ts",
"import": "./dist/dynamic-workflows/index.js"
},
"./framework": {
"types": "./dist/framework/index.d.ts",
"import": "./dist/framework/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/think/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ async function main() {
entry: [
"src/think.ts",
"src/workflows.ts",
"src/dynamic-workflows/index.ts",
"src/extensions/index.ts",
"src/framework/index.ts",
"src/server-entry.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/think/src/dynamic-workflows/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DynamicThinkWorkflow } from "./loader";
81 changes: 81 additions & 0 deletions packages/think/src/dynamic-workflows/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
createDynamicWorkflowEntrypoint,
type WorkflowRunner
} from "@cloudflare/dynamic-workflows";

type DynamicThinkWorkflowMeta = {
wfId: string;
agentBinding: string;
agentName: string;
};

type DynamicThinkWorkflowEnv = {
LOADER: WorkerLoader;
[key: string]: unknown;
};

type AgentStub = {
getWorkflowCode(wfId: string): Promise<string>;
};

/**
* DynamicThinkWorkflow — register this as the `class_name` in your
* `[[workflows]]` wrangler binding. When the engine calls `run()`, it
* loads the generated ThinkWorkflow code from the agent via RPC, bundles
* it with its npm dependencies (ThinkWorkflow, zod, etc.) via worker-bundler,
* loads it as a Dynamic Worker, and dispatches execution to it.
*
* The generated code runs as a real ThinkWorkflow — `step.prompt()`,
* `this.agent`, `step.do()`, `step.waitForEvent()` all work natively.
*
* @example
* ```ts
* // wrangler.jsonc
* {
* "workflows": [{
* "name": "dynamic-think",
* "binding": "DYNAMIC_THINK_WF",
* "class_name": "DynamicThinkWorkflow"
* }]
* }
*
* // server.ts
* export { DynamicThinkWorkflow } from "@cloudflare/think/dynamic-workflows";
* ```
*/
export const DynamicThinkWorkflow =
createDynamicWorkflowEntrypoint<DynamicThinkWorkflowEnv>(
async ({ env, metadata }) => {
const { createWorker } = await import("@cloudflare/worker-bundler");
const meta = metadata as unknown as DynamicThinkWorkflowMeta;

const agentNS = env[meta.agentBinding] as DurableObjectNamespace;
const agentStub = agentNS.get(
agentNS.idFromName(meta.agentName)
) as unknown as AgentStub;
const code = await agentStub.getWorkflowCode(meta.wfId);

const { mainModule, modules } = await createWorker({
files: {
"workflow.ts": code,
"package.json": JSON.stringify({
dependencies: {
"@cloudflare/think": "*",
zod: "*"
}
})
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Bundler package.json missing agents dependency used by generated code

The loader's hardcoded package.json at packages/think/src/dynamic-workflows/loader.ts:61-66 only lists @cloudflare/think and zod as dependencies. However, the example's generated code (examples/dynamic-think-workflows/src/index.ts:54) imports import type { AgentWorkflowEvent } from "agents/workflows". While import type is erased at runtime, if any generated code does a value import from agents, the bundler would fail to resolve it. Since agents is a peer dependency of @cloudflare/think, whether it's transitively available depends on how @cloudflare/worker-bundler resolves dependencies. Users generating code with runtime imports from agents would hit bundling errors.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({
mainModule,
modules,
compatibilityDate: "2026-01-01",
env: { [meta.agentBinding]: env[meta.agentBinding] }
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Dynamic worker missing nodejs_compat flag and uses stale compatibility date

The dynamic worker created in the loader at packages/think/src/dynamic-workflows/loader.ts:70-75 sets compatibilityDate: "2026-01-01" but omits compatibilityFlags. The parent worker (and every wrangler.jsonc in the repo) uses compatibilityDate: "2026-06-11" with compatibilityFlags: ["nodejs_compat"]. The generated workflow code bundles @cloudflare/think and zod, which may rely on Node.js built-in APIs (e.g. crypto, Buffer, streams) that are only available when nodejs_compat is enabled. Without the flag, the dynamic worker will fail at runtime if any bundled dependency references a Node.js built-in. The stale date (2026-01-01 vs 2026-06-11) could also cause behavioral differences between the host worker and the dynamic worker.

Suggested change
const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({
mainModule,
modules,
compatibilityDate: "2026-01-01",
env: { [meta.agentBinding]: env[meta.agentBinding] }
}));
const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({
mainModule,
modules,
compatibilityDate: "2026-06-11",
compatibilityFlags: ["nodejs_compat"],
env: { [meta.agentBinding]: env[meta.agentBinding] }
}));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Dynamic worker env only forwards the agent binding — other bindings are unavailable

At packages/think/src/dynamic-workflows/loader.ts:74, the dynamic worker's env is set to { [meta.agentBinding]: env[meta.agentBinding] }, forwarding only the agent's DurableObjectNamespace. If a generated workflow's run() method needs other bindings (e.g., AI for Workers AI, KV, R2, or other DO namespaces), they won't be available. The example at examples/dynamic-think-workflows/src/index.ts:70-74 uses step.prompt() which invokes LLM calls — whether this resolves through the agent stub (via RPC) or needs a direct AI binding determines if this is a problem. Worth verifying that step.prompt() in ThinkWorkflow delegates LLM calls through the agent's DO rather than requiring a local AI binding.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return worker.getEntrypoint(
"GeneratedWorkflow"
) as unknown as WorkflowRunner;
Comment on lines +115 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Hardcoded entrypoint name "GeneratedWorkflow" creates undocumented contract

The loader at packages/think/src/dynamic-workflows/loader.ts:77-78 calls worker.getEntrypoint("GeneratedWorkflow"), which means ALL generated workflow code must export a class named exactly GeneratedWorkflow. This requirement is not documented in the runDynamicWorkflow JSDoc (packages/think/src/think.ts:7449-7463), which only says "The generated code must export a default class that extends ThinkWorkflow." If a user names their class differently (e.g., ResearchWorkflow), or uses an anonymous default export, the entrypoint lookup will fail at runtime. Consider either documenting this constraint clearly in the JSDoc, or making the entrypoint name configurable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
);
Loading
Loading