Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Effectful-Tech/clanka/llms.txt

Use this file to discover all available pages before exploring further.

Custom tools let you give your agent new capabilities beyond the built-in set — anything from sending notifications to querying an internal API. Clanka integrates with Effect’s Tool and Toolkit APIs so that each tool is fully typed, schema-validated, and composable with the rest of your Effect application.

How tools work in Clanka

When the agent executes a script, every tool in the toolkit is injected into the VM sandbox as a plain async JavaScript function: await yourTool(params). The agent sees each function as a TypeScript declare function declaration (generated by ToolkitRenderer) in the system prompt, so it knows the exact parameter shape and return type. The parameter types are rendered from the Schema you provide to Tool.make. For example, a struct schema produces a typed object literal declaration, while a plain Schema.String produces a string argument named after the schema’s identifier annotation.

Building a custom tool

1

Declare the tool with Tool.make()

Use Tool.make from effect/unstable/ai/Tool to define the tool’s name, description, parameter schema, success schema, and any context dependencies.
import * as Tool from "effect/unstable/ai/Tool"
import * as Schema from "effect/Schema"
import { CurrentDirectory } from "clanka"

const NotifyTool = Tool.make("notify", {
  description: "Send a desktop notification with a title and message.",
  parameters: Schema.Struct({
    title: Schema.String,
    message: Schema.String,
  }),
  success: Schema.String,
  // Declare any Effect context services the handler needs
  dependencies: [CurrentDirectory],
})
The dependencies array tells Clanka which context services to inject when the handler runs. Built-in services you can request are described below.
2

Group tools into a Toolkit with Toolkit.make()

Wrap one or more tools in a Toolkit so they can be merged and provided together.
import * as Toolkit from "effect/unstable/ai/Toolkit"

export const NotifyToolkit = Toolkit.make(NotifyTool)
If you have several custom tools, pass them all to a single Toolkit.make call. You can also merge toolkits with Toolkit.merge.
3

Implement handlers with toolkit.toLayer()

Call .toLayer() on the toolkit to produce an Effect Layer that provides the handler implementations.
import * as Effect from "effect/Effect"
import { CurrentDirectory } from "clanka"

export const NotifyToolHandlers = NotifyToolkit.toLayer(
  Effect.gen(function* () {
    return NotifyToolkit.of({
      notify: Effect.fn("NotifyTool.notify")(function* (options) {
        const cwd = yield* CurrentDirectory
        yield* Effect.logInfo("Sending notification").pipe(
          Effect.annotateLogs({ cwd, ...options }),
        )
        // Replace with your real notification logic:
        yield* Effect.promise(() =>
          fetch("https://notify.example.com/send", {
            method: "POST",
            body: JSON.stringify(options),
          }),
        )
        return `Notification sent: ${options.title}`
      }),
    })
  }),
)
The handler receives the decoded parameters as its first argument. Yield any Effect-based logic you need — file system access, HTTP calls, logging — and return the success value.
4

Provide the toolkit to Agent.layerLocal()

Pass your toolkit to the tools option of Agent.layerLocal. Clanka automatically merges it with the built-in tools and makes the handlers available in the VM sandbox.
import * as Agent from "clanka/Agent"
import * as Layer from "effect/Layer"

const AgentLayer = Agent.layerLocal({
  directory: process.cwd(),
  tools: NotifyToolkit,
}).pipe(
  Layer.provide(NotifyToolHandlers),
  // ...other platform layers
)
The type signature of layerLocal enforces that every handler required by your toolkit — except the three built-in context services — is provided before the layer is used.

Context services available to handlers

Custom tool handlers run inside the same Effect context as the built-in tools. You can declare any of the following in your tool’s dependencies array and then yield them in your handler.
Type: stringThe absolute path of the working directory the agent is operating in. Declared in AgentTools.ts as:
export class CurrentDirectory extends Context.Service<
  CurrentDirectory,
  string
>()("clanka/AgentTools/CurrentDirectory") {}
Use it to resolve relative file paths inside your handler.
Type: (prompt: string) => Effect.Effect<string>A function that spawns a sub-agent with the given prompt and returns its final summary. Useful if your tool needs to delegate work to another agent run.
export class SubagentExecutor extends Context.Service<
  SubagentExecutor,
  (prompt: string) => Effect.Effect<string>
>()("clanka/AgentTools/SubagentExecutor") {}
Type: (output: string) => Effect.Effect<void>Signals task completion with a final summary string. The built-in taskComplete tool uses this service. Only declare it as a dependency if your custom tool should be able to terminate the agent’s current task.
export class TaskCompleter extends Context.Service<
  TaskCompleter,
  (output: string) => Effect.Effect<void>
>()("clanka/AgentTools/TaskCompleter") {}

How parameter types appear in the system prompt

ToolkitRenderer converts each tool’s Schema into a TypeScript declare function declaration and injects it into the agent’s system prompt. Given the notify tool above, the agent sees:
/** Send a desktop notification with a title and message. */
declare function notify(options: { title: string; message: string }): Promise<string>
The agent then calls the function exactly as declared:
// Inside a script the agent writes and executes:
const result = await notify({ title: "Done", message: "The task is complete." })
console.log(result)
Tool names must be valid JavaScript identifiers. If a parameter schema has an identifier annotation (Schema.String.annotate({ identifier: "path" })), that annotation is used as the argument name in the declaration.

Full example: a “notify” tool

import * as Tool from "effect/unstable/ai/Tool"
import * as Toolkit from "effect/unstable/ai/Toolkit"
import * as Schema from "effect/Schema"
import * as Effect from "effect/Effect"
import * as Agent from "clanka/Agent"
import * as Layer from "effect/Layer"
import { NodeServices } from "@effect/platform-node"
import { NodeHttpClient } from "@effect/platform-node"

// 1. Declare the tool
const NotifyTool = Tool.make("notify", {
  description: "Send a desktop notification with a title and message.",
  parameters: Schema.Struct({
    title: Schema.String,
    message: Schema.String,
  }),
  success: Schema.String,
})

// 2. Group into a Toolkit
const NotifyToolkit = Toolkit.make(NotifyTool)

// 3. Implement the handler
const NotifyToolHandlers = NotifyToolkit.toLayer(
  Effect.gen(function* () {
    return NotifyToolkit.of({
      notify: Effect.fn("NotifyTool.notify")(function* (options) {
        yield* Effect.logInfo("Sending notification").pipe(
          Effect.annotateLogs(options),
        )
        await fetch("https://notify.example.com/send", {
          method: "POST",
          body: JSON.stringify(options),
        })
        return `Notification sent: ${options.title}`
      }),
    })
  }),
)

// 4. Wire everything together
const AgentLayer = Agent.layerLocal({
  directory: process.cwd(),
  tools: NotifyToolkit,
}).pipe(
  Layer.provide(NotifyToolHandlers),
  Layer.provide(NodeServices.layer),
  Layer.provide(NodeHttpClient.layerUndici),
)
You can merge multiple custom toolkits by calling Toolkit.merge before passing the result to layerLocal. Handlers for each toolkit are provided separately via their own toLayer() calls.

Build docs developers (and LLMs) love