effect-agentsv4 · beta

integrations · think

Think + Effect

The smallest Think DO whose custom tool body is an Effect program.

integrations/think/worker.ts

// integrations/think — the smallest Think DO whose custom tool body is an
// Effect program.
//
// The tool registration is pure Think (via the `ai` SDK's `tool()` factory).
// The tool's `execute` function runs an `Effect.gen` block and resolves with
// `Effect.runPromise`. That one line is the only seam between Think (the host)
// and Effect (the body).
//
// Mirrors think-snippets/examples/effect-hello.
//   https://github.com/acoyfellow/think-snippets/tree/main/examples/effect-hello

import { Think } from "@cloudflare/think"
import { getAgentByName } from "agents"
import { tool } from "ai"
import { createWorkersAI } from "workers-ai-provider"
import { z } from "zod"
import { Effect } from "effect"

export interface Env {
  AI: Ai
  Greeter: DurableObjectNamespace<Greeter>
}

interface UIMessageChunk {
  type: string
  delta?: string
  text?: string
}

interface StreamCallback {
  onEvent: (json: string) => void
  onDone?: () => void
  onError?: (message: string) => void
}

// The Effect program — pure, no Think, no `ai` SDK, no env.
// Inputs are plain values; output is a string.
const greetEffect = (name: string) =>
  Effect.gen(function* () {
    yield* Effect.sleep("50 millis") // proves the Effect actually runs
    if (!name.trim()) {
      return yield* Effect.fail(new Error("name is required"))
    }
    return `Hello, ${name.trim()}! Welcome to Think + Effect.`
  }).pipe(Effect.timeout("5 seconds"))

export class Greeter extends Think<Env> {
  getModel() {
    return createWorkersAI({ binding: this.env.AI })("@cf/moonshotai/kimi-k2.6")
  }

  getSystemPrompt() {
    return [
      "You are a greeting assistant.",
      "When the user gives you a name to greet, you MUST call the `greet` tool with that name.",
      "Reply with exactly the tool result and nothing else."
    ].join(" ")
  }

  getTools() {
    return {
      greet: tool({
        description: "Greet a person by name. Returns a friendly greeting string.",
        inputSchema: z.object({
          name: z.string().min(1).max(120).describe("The name of the person to greet.")
        }),
        // The seam between Think (the host) and Effect (the body).
        execute: async ({ name }) => {
          const greeting = await Effect.runPromise(greetEffect(name))
          return { greeting }
        }
      })
    }
  }
}

The smallest Think DO whose custom tool body is an Effect program.

Think handles the DO state, the chat protocol, the tool-call lifecycle, and the assistant-answer streaming. The custom tool's body is an Effect.gen(...) block executed with one Effect.runPromise(...) call. That single line is the only seam.

The seam

execute: async ({ name }) => {
  const greeting = await Effect.runPromise(greetEffect(name))
  return { greeting }
}

The execute function is just an async function — Think doesn't care what runs inside it. Run an Effect program; the tool result reaches the assistant answer like any other tool.

Composition

What Think gives you What Effect gives you
DO state per-session SQLite, chat memory, the tool-call protocol
Tool body tool registration via tool() from the ai SDK typed errors, timeouts, retries, structured concurrency
Scaling up more tools, hooks (afterToolCall), audit scaling the body of each tool — concurrency, branching, structured output, all composable

If you want to see what real Effect agents look like — concurrency, retry, streaming, approval flows, typed errors, MCP — see the five agents in this repo.

Live in the Think repo

This file is also shipped as a runnable example in think-snippets/examples/effect-hello, where it deploys via Alchemy under the personal-account guard, runs an end-to-end probe that drives a real chat turn against the deployed Worker, and asserts the Effect-baked literal reaches the assistant answer.

# from acoyfellow/think-snippets, with CLOUDFLARE_PERSONAL_* set
bash examples/effect-hello/run-e2e.sh