effect-codemode 0.0.1 → 0.1.0-beta.0

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.
package/README.md CHANGED
@@ -1,5 +1,641 @@
1
1
  # effect-codemode
2
2
 
3
- Effect-native codemode for MCP — give LLMs a typed Effect SDK instead of individual tool calls.
3
+ Give LLMs a typed Effect SDK instead of individual tool calls.
4
4
 
5
- Coming soon. See https://github.com/aulneau/effect-codemode
5
+ effect-codemode implements the [codemode pattern](https://blog.cloudflare.com/code-mode-mcp/) for [MCP](https://modelcontextprotocol.io) — collapsing N tools into 2 (`search_tools` + `execute_code`) so the LLM writes typed Effect code against your services instead of making one tool call at a time.
6
+
7
+ ## The problem
8
+
9
+ Traditional MCP exposes each operation as a separate tool. A workspace with Linear, GitHub, Sheets, and Slack integrations might have 36+ tools — each loaded into the LLM's context at ~500 tokens each. That's 18,000 tokens before the LLM has done anything.
10
+
11
+ Worse, composition requires round trips. "Find my Linear issues and check for linked PRs" means:
12
+
13
+ 1. LLM calls `linear_issue_list` -> waits for result
14
+ 2. LLM calls `gh_list_prs` -> waits for result
15
+ 3. LLM reasons about both -> responds
16
+
17
+ Each round trip burns tokens and adds latency. A 5-step workflow means 5 round trips.
18
+
19
+ ## The codemode pattern
20
+
21
+ With effect-codemode, the LLM writes one code block:
22
+
23
+ ```typescript
24
+ Effect.gen(function* () {
25
+ const issues = yield* codemode.linear_issue_list({ assigneeId: me.id, first: 10 })
26
+ const prs = yield* codemode.gh_list_prs({ owner: "Org", repo: "app", state: "open" })
27
+ return issues.filter(i => prs.some(p => p.title.includes(i.identifier)))
28
+ })
29
+ ```
30
+
31
+ One round trip. Multiple API calls. Data filtering happens in the sandbox, not in the LLM's context. And because this is Effect, the LLM gets typed errors, structured concurrency, and composable resilience — not just `async/await`.
32
+
33
+ ## Quick start
34
+
35
+ ### Install
36
+
37
+ ```bash
38
+ bun add effect-codemode effect @effect/ai @effect/platform
39
+ ```
40
+
41
+ ### 1. Define your schemas and errors
42
+
43
+ ```typescript
44
+ import { Context, Effect, Layer, Schema } from "effect"
45
+
46
+ const Todo = Schema.Struct({
47
+ id: Schema.String.annotations({ description: "Todo UUID" }),
48
+ title: Schema.String.annotations({ description: "Short summary" }),
49
+ completed: Schema.Boolean,
50
+ }).annotations({ identifier: "Todo" })
51
+
52
+ type Todo = typeof Todo.Type
53
+
54
+ class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {
55
+ message: Schema.String,
56
+ }) {}
57
+
58
+ class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
59
+ message: Schema.String,
60
+ field: Schema.String,
61
+ }) {}
62
+ ```
63
+
64
+ ### 2. Define tool shapes
65
+
66
+ Tool shapes are pure data descriptors — name, description, input/output schemas, error types, and flags. No implementation.
67
+
68
+ ```typescript
69
+ import { Codemode } from "effect-codemode"
70
+
71
+ const TodoGet = Codemode.tool("todo_get", {
72
+ description: "Get a todo by ID",
73
+ input: Schema.Struct({ id: Schema.String.annotations({ description: "Todo UUID" }) }),
74
+ output: Todo,
75
+ errors: [NotFoundError],
76
+ example: 'yield* codemode.todo_get({ id: "todo-1" })',
77
+ })
78
+
79
+ const TodoList = Codemode.tool("todo_list", {
80
+ description: "List todos with optional filter",
81
+ input: Schema.Struct({
82
+ completed: Schema.optional(Schema.Boolean.annotations({ description: "Filter by status" }), { exact: true }),
83
+ }),
84
+ output: Schema.Array(Todo),
85
+ example: "yield* codemode.todo_list({ completed: false })",
86
+ })
87
+
88
+ const TodoCreate = Codemode.tool("todo_create", {
89
+ description: "Create a new todo",
90
+ input: Schema.Struct({
91
+ title: Schema.String.annotations({ description: "Title for the new todo" }),
92
+ }),
93
+ output: Todo,
94
+ errors: [ValidationError],
95
+ example: 'yield* codemode.todo_create({ title: "New task" })',
96
+ })
97
+ ```
98
+
99
+ ### 3. Create a group with `.toLayer()`
100
+
101
+ Groups bundle related tool shapes and bind them to an implementation via `.toLayer()`. The handlers can depend on Effect services.
102
+
103
+ ```typescript
104
+ class TodoService extends Context.Tag("TodoService")<
105
+ TodoService,
106
+ {
107
+ readonly get: (id: string) => Effect.Effect<Todo, NotFoundError>
108
+ readonly list: (filter?: { completed?: boolean }) => Effect.Effect<Todo[]>
109
+ readonly create: (input: { title: string }) => Effect.Effect<Todo, ValidationError>
110
+ }
111
+ >() {}
112
+
113
+ class TodoTools extends Codemode.group("todos", TodoGet, TodoList, TodoCreate) {}
114
+
115
+ const TodoToolsLive = TodoTools.toLayer(
116
+ Effect.gen(function* () {
117
+ const svc = yield* TodoService
118
+ return {
119
+ todo_get: ({ id }) => svc.get(id),
120
+ todo_list: (input) => svc.list(input),
121
+ todo_create: (input) => svc.create(input),
122
+ }
123
+ }),
124
+ ).pipe(Layer.provide(TodoServiceLive))
125
+ ```
126
+
127
+ Or pass handlers directly without an Effect if there are no service dependencies:
128
+
129
+ ```typescript
130
+ const TodoToolsLive = TodoTools.toLayer({
131
+ todo_get: ({ id }) => Effect.succeed(todos.find(t => t.id === id)),
132
+ todo_list: (input) => Effect.succeed(todos),
133
+ todo_create: (input) => Effect.succeed({ id: "new", title: input.title, completed: false }),
134
+ })
135
+ ```
136
+
137
+ ### 4. Serve
138
+
139
+ The quickest way to get running is `Codemode.live()`, which bundles `Codemode.serve()` with the default VM executor:
140
+
141
+ ```typescript
142
+ import { Codemode, CodemodeRegistry } from "effect-codemode"
143
+ import { McpServer } from "@effect/ai"
144
+ import { Effect, Layer } from "effect"
145
+
146
+ Codemode.live().pipe(
147
+ Layer.provide(TodoToolsLive),
148
+ Layer.provide(CodemodeRegistry.layer),
149
+ Layer.provide(McpServer.layerStdio({
150
+ name: "my-mcp-server",
151
+ version: "1.0.0",
152
+ stdin,
153
+ stdout,
154
+ })),
155
+ Layer.launch,
156
+ Effect.runFork,
157
+ )
158
+ ```
159
+
160
+ For more control, use `Codemode.serve()` and provide the executor explicitly:
161
+
162
+ ```typescript
163
+ import { Codemode, CodemodeRegistry, CodeExecutor } from "effect-codemode"
164
+
165
+ Codemode.serve().pipe(
166
+ Layer.provide(TodoToolsLive),
167
+ Layer.provide(CodemodeRegistry.layer),
168
+ Layer.provide(CodeExecutor.Default),
169
+ Layer.provide(McpServer.layerStdio({
170
+ name: "my-mcp-server",
171
+ version: "1.0.0",
172
+ stdin,
173
+ stdout,
174
+ })),
175
+ Layer.launch,
176
+ Effect.runFork,
177
+ )
178
+ ```
179
+
180
+ Or use the `StdioTransport` helper if you have `@effect/platform-bun` or `@effect/platform-node`:
181
+
182
+ ```typescript
183
+ import { BunRuntime } from "@effect/platform-bun"
184
+ import { BunSink, BunStream } from "@effect/platform-bun"
185
+
186
+ Codemode.live().pipe(
187
+ Layer.provide(TodoToolsLive),
188
+ Layer.provide(CodemodeRegistry.layer),
189
+ Layer.provide(Codemode.StdioTransport(BunStream.stdin, BunSink.stdout, {
190
+ name: "my-mcp-server",
191
+ version: "1.0.0",
192
+ })),
193
+ Layer.launch,
194
+ BunRuntime.runMain,
195
+ )
196
+ ```
197
+
198
+ ### 5. Connect to Claude Code
199
+
200
+ Create `.mcp.json` in your project root:
201
+
202
+ ```json
203
+ {
204
+ "mcpServers": {
205
+ "my-tools": {
206
+ "command": "bun",
207
+ "args": ["run", "src/server.ts"]
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ ## What the LLM sees
214
+
215
+ effect-codemode walks your Effect Schema AST and generates TypeScript declarations. When the LLM calls `execute_code`, this is in the tool description:
216
+
217
+ ```typescript
218
+ type NotFoundError = { readonly _tag: "NotFoundError"; readonly message: string }
219
+
220
+ type Todo = {
221
+ id: string
222
+ title: string
223
+ completed: boolean
224
+ }
225
+
226
+ declare const codemode: {
227
+ /** Get a todo by ID
228
+ * @param id - Todo UUID
229
+ * @error NotFoundError
230
+ */
231
+ todo_get(input: { id: string }): Effect<Todo, NotFoundError>
232
+
233
+ /** List todos with optional filter */
234
+ todo_list(input: { completed?: boolean }): Effect<Todo[], never>
235
+ }
236
+ ```
237
+
238
+ The LLM writes natural Effect code against this typed SDK. Schema annotations become `@param` descriptions. Error types are deduplicated across tools. Output types shared between tools are emitted once.
239
+
240
+ When the tool count exceeds 7, the description automatically switches to a compact one-line-per-tool catalog and directs the LLM to use `search_tools` for full type signatures.
241
+
242
+ ## Effect patterns in the sandbox
243
+
244
+ The sandbox has `Effect`, `pipe`, `Duration`, `Schedule`, `Option`, `Either`, `Match`, `Schema`, `Arr` (Effect's Array module), `Ref`, `Stream`, `Chunk`, `Data`, `Order`, and `Predicate` available as globals. Helper functions `parallel()` and `sleep(ms)` are also available. Each method on `codemode` returns an Effect — use `yield*` inside `Effect.gen` to call them.
245
+
246
+ ### Typed error handling
247
+
248
+ ```typescript
249
+ Effect.gen(function* () {
250
+ return yield* codemode.todo_get({ id: "nonexistent" })
251
+ }).pipe(
252
+ Effect.catchTag("NotFoundError", (e) =>
253
+ Effect.succeed({ error: e.message, fallback: true })
254
+ )
255
+ )
256
+ ```
257
+
258
+ ### Parallel composition
259
+
260
+ ```typescript
261
+ Effect.gen(function* () {
262
+ const [todos, users] = yield* Effect.all([
263
+ codemode.todo_list({}),
264
+ codemode.user_list({}),
265
+ ], { concurrency: "unbounded" })
266
+ return { todos: todos.length, users: users.length }
267
+ })
268
+ ```
269
+
270
+ ### Retry with backoff
271
+
272
+ ```typescript
273
+ Effect.gen(function* () {
274
+ return yield* codemode.flaky_api_call({})
275
+ }).pipe(
276
+ Effect.retry({ times: 3, schedule: Schedule.exponential("1 second") })
277
+ )
278
+ ```
279
+
280
+ ### Timeout
281
+
282
+ ```typescript
283
+ Effect.gen(function* () {
284
+ return yield* codemode.slow_operation({})
285
+ }).pipe(Effect.timeout("10 seconds"))
286
+ ```
287
+
288
+ ### Progress streaming
289
+
290
+ `console.log` in sandbox code streams as MCP logging notifications in real-time:
291
+
292
+ ```typescript
293
+ Effect.gen(function* () {
294
+ console.log("Fetching todos...")
295
+ const todos = yield* codemode.todo_list({})
296
+ console.log(`Got ${todos.length} todos, processing...`)
297
+ return todos
298
+ })
299
+ ```
300
+
301
+ ## Multiple services
302
+
303
+ Compose groups for cross-service workflows:
304
+
305
+ ```typescript
306
+ Codemode.live().pipe(
307
+ Layer.provide(Layer.mergeAll(TaskToolsLive, ProjectToolsLive, SlackToolsLive)),
308
+ Layer.provide(CodemodeRegistry.layer),
309
+ Layer.provide(McpServer.layerStdio({
310
+ name: "my-workspace",
311
+ version: "1.0.0",
312
+ stdin,
313
+ stdout,
314
+ })),
315
+ Layer.launch,
316
+ Effect.runFork,
317
+ )
318
+ ```
319
+
320
+ The LLM can now compose across all services in one code block:
321
+
322
+ ```typescript
323
+ Effect.gen(function* () {
324
+ const user = yield* codemode.user_get({ id: "user-1" })
325
+ const todos = yield* codemode.todo_list({ assignee: user.id })
326
+
327
+ yield* Effect.all(
328
+ todos.map(todo =>
329
+ codemode.slack_send({
330
+ channel: "#tasks",
331
+ message: `${user.name}: ${todo.title}`,
332
+ })
333
+ ),
334
+ { concurrency: 5 }
335
+ )
336
+
337
+ return { notified: todos.length }
338
+ })
339
+ ```
340
+
341
+ Failed groups are automatically excluded at startup. The server starts with whatever's available, and service status is included in the tool description so the LLM knows what's online.
342
+
343
+ ## Features
344
+
345
+ ### Middleware
346
+
347
+ Add cross-cutting concerns to all tool executions:
348
+
349
+ ```typescript
350
+ import { Codemode } from "effect-codemode"
351
+ import type { ToolMiddleware } from "effect-codemode"
352
+
353
+ const loggingMiddleware: ToolMiddleware = (tool, next) =>
354
+ Effect.gen(function* () {
355
+ console.log(`Calling ${tool.name}`)
356
+ const start = Date.now()
357
+ const result = yield* next
358
+ console.log(`${tool.name} took ${Date.now() - start}ms`)
359
+ return result
360
+ })
361
+
362
+ // Global middleware wraps ALL tools from every group
363
+ Codemode.serve().pipe(
364
+ Layer.provide(Codemode.withMiddleware(loggingMiddleware)),
365
+ Layer.provide(Layer.mergeAll(TodoToolsLive, UserToolsLive)),
366
+ Layer.provide(CodemodeRegistry.layer),
367
+ // ...transport + executor
368
+ )
369
+ ```
370
+
371
+ Middleware receives a `ToolInvocation` (with `name` and `input`) and a `next` Effect. You can inspect the invocation, transform results, catch errors, or skip execution entirely.
372
+
373
+ #### Per-group middleware
374
+
375
+ Attach middleware to a specific group using `.middleware()`:
376
+
377
+ ```typescript
378
+ class TodoTools extends Codemode.group("todos", TodoGet, TodoList, TodoCreate)
379
+ .middleware(auditMiddleware, rateLimitMiddleware) {}
380
+ ```
381
+
382
+ Or pass middleware via `.toLayer()` options:
383
+
384
+ ```typescript
385
+ const TodoToolsLive = TodoTools.toLayer(handlers, {
386
+ middleware: [auditMiddleware],
387
+ })
388
+ ```
389
+
390
+ #### Per-tool middleware
391
+
392
+ Attach middleware to individual tool shapes:
393
+
394
+ ```typescript
395
+ const TodoDelete = Codemode.tool("todo_delete", {
396
+ description: "Delete a todo permanently",
397
+ input: Schema.Struct({ id: Schema.String }),
398
+ output: Schema.Struct({ deleted: Schema.Boolean }),
399
+ middleware: [auditMiddleware],
400
+ })
401
+ ```
402
+
403
+ ### Confirmation-required tools
404
+
405
+ Tools with `requiresConfirmation: true` are excluded from the sandbox and registered as separate MCP tools, so the MCP client can show a confirmation dialog:
406
+
407
+ ```typescript
408
+ const TodoDelete = Codemode.tool("todo_delete", {
409
+ description: "Delete a todo permanently",
410
+ input: Schema.Struct({ id: Schema.String }),
411
+ output: Schema.Struct({ deleted: Schema.Boolean }),
412
+ errors: [NotFoundError],
413
+ requiresConfirmation: true,
414
+ })
415
+ ```
416
+
417
+ ### Tool search
418
+
419
+ The `search_tools` MCP tool supports keyword search, service browsing, fuzzy matching, and configurable detail levels:
420
+
421
+ ```
422
+ search_tools({ query: "todo" }) // keyword search
423
+ search_tools({ query: "service:todos" }) // browse by service
424
+ search_tools({ query: "isue" }) // fuzzy match -> "issue"
425
+ search_tools({ query: "" }) // list all tools
426
+ search_tools({ query: "todo", detail: "list" }) // names + descriptions only
427
+ search_tools({ query: "todo", detail: "summary" }) // names + descriptions + params
428
+ search_tools({ query: "todo", detail: "full" }) // complete TypeScript declarations (default)
429
+ ```
430
+
431
+ Multi-term queries use AND semantics — all terms must match. Workflow queries like `"sheets read write"` fall back to returning all tools for the matched service.
432
+
433
+ ### Network isolation
434
+
435
+ Block network access in the sandbox:
436
+
437
+ ```typescript
438
+ const result = await executor.execute(code, fns, { allowNetwork: false })
439
+ ```
440
+
441
+ When disabled, `fetch`, `URL`, `Request`, `Headers`, and `Response` throw clear errors.
442
+
443
+ ### Code executor
444
+
445
+ `CodeExecutor` is an Effect service tag with multiple implementations:
446
+
447
+ ```typescript
448
+ import { CodeExecutor } from "effect-codemode"
449
+
450
+ CodeExecutor.Vm // node:vm — stronger isolation (default)
451
+ CodeExecutor.Direct // new Function() — faster, less isolated
452
+ CodeExecutor.Default // alias for Vm
453
+ ```
454
+
455
+ Provide the executor when using `Codemode.serve()`:
456
+
457
+ ```typescript
458
+ Codemode.serve().pipe(
459
+ Layer.provide(CodeExecutor.Default), // or CodeExecutor.Direct
460
+ // ...
461
+ )
462
+ ```
463
+
464
+ Or use `Codemode.live()` which bundles `CodeExecutor.Default` automatically.
465
+
466
+ ### Execution timeout
467
+
468
+ The sandbox has a default 30-second timeout. Configure it via `ExecutorOptions`:
469
+
470
+ ```typescript
471
+ executor.execute(code, fns, { timeoutMs: 60_000 }) // 60 seconds
472
+ ```
473
+
474
+ ### Auto-retry on rate limits
475
+
476
+ Tool calls that fail with a `RateLimitError` (or any error with `status: 429`) are automatically retried up to 3 times with exponential backoff.
477
+
478
+ ## Testing
479
+
480
+ effect-codemode exports testing utilities from `effect-codemode/test`:
481
+
482
+ ```typescript
483
+ import { buildTestTool, buildInspector } from "effect-codemode/test"
484
+ ```
485
+
486
+ ### `buildTestTool`
487
+
488
+ Creates a full CodemodeTool from group layers — run sandbox code without starting an MCP server:
489
+
490
+ ```typescript
491
+ const codemode = buildTestTool(TodoToolsLive, UserToolsLive)
492
+
493
+ const result = await codemode.execute(`
494
+ Effect.gen(function* () {
495
+ const todos = yield* codemode.todo_list({})
496
+ return todos.length
497
+ })
498
+ `)
499
+ // result.result === 3
500
+ // result.logs === [...]
501
+ ```
502
+
503
+ ### `buildInspector`
504
+
505
+ Returns just the TypeScript declarations string — useful for snapshot testing your generated API:
506
+
507
+ ```typescript
508
+ const declarations = buildInspector(TodoToolsLive)
509
+ expect(declarations).toContain("todo_get")
510
+ ```
511
+
512
+ ## Architecture
513
+
514
+ ```
515
+ MCP Client (Claude Code, Cursor, etc.)
516
+ | search_tools / execute_code
517
+ @effect/ai McpServer (stdio / HTTP transport)
518
+ |
519
+ serve.ts -> registry snapshot -> tool registration
520
+ |
521
+ codemode.ts -> typegen (declarations) + bridge (fn table) + executor
522
+ |
523
+ +----------------------------------------------------------+
524
+ | Sandbox |
525
+ | |
526
+ | Globals: Effect, pipe, Duration, Schedule, Option, |
527
+ | Either, Match, Schema, Arr, Ref, Stream, Chunk, Data, |
528
+ | Order, Predicate, parallel, sleep, codemode.* |
529
+ | |
530
+ | codemode.tool_name(input) |
531
+ | -> Schema.decodeUnknown (input validation) |
532
+ | -> middleware chain |
533
+ | -> Effect service method (via captured Runtime) |
534
+ | -> unwrapEffectTypes (Option -> null, Either -> value) |
535
+ | -> auto-retry on RateLimitError |
536
+ | -> returns Effect<Output, Error> |
537
+ | |
538
+ | LLM writes: Effect.gen(function* () { yield* ... }) |
539
+ | Executor detects Effect return -> Effect.runPromise() |
540
+ +----------------------------------------------------------+
541
+ |
542
+ Effect Services (your existing Context.Tag + Layer stack)
543
+ ```
544
+
545
+ ## API reference
546
+
547
+ ### Core
548
+
549
+ | Export | Description |
550
+ |--------|-------------|
551
+ | `Codemode.tool(name, config)` | Define a tool shape — pure data descriptor with no implementation |
552
+ | `Codemode.group(name, ...tools)` | Bundle related tools into a group class with `.toLayer()` and `.middleware()` |
553
+ | `Codemode.serve()` | Return a Layer that sets up the full MCP server (requires `CodeExecutor`) |
554
+ | `Codemode.live()` | Convenience — `serve()` with `CodeExecutor.Default` bundled |
555
+ | `Codemode.StdioTransport(stdin, stdout, options?)` | Stdio transport for MCP — provides McpServer |
556
+ | `Codemode.CodemodeRegistry` | Shared registry tag — also available as named export |
557
+ | `CodemodeRegistry.layer` | Shared registry Layer — provide once so groups and serve share state |
558
+ | `Codemode.withMiddleware(mw)` | Register a global middleware Layer |
559
+
560
+ ### Tool config
561
+
562
+ | Field | Type | Description |
563
+ |-------|------|-------------|
564
+ | `description` | `string` | What the tool does |
565
+ | `input` | `Schema` | Input schema |
566
+ | `output` | `Schema?` | Output schema |
567
+ | `errors` | `TaggedError[]?` | Error types the tool can fail with |
568
+ | `example` | `string?` | Example code shown to the LLM |
569
+ | `requiresConfirmation` | `boolean?` | Register as separate MCP tool for confirmation |
570
+ | `middleware` | `ToolMiddleware[]?` | Per-tool middleware |
571
+
572
+ ### Executor
573
+
574
+ | Export | Description |
575
+ |--------|-------------|
576
+ | `CodeExecutor` | Effect service tag for code execution |
577
+ | `CodeExecutor.Vm` | V8-isolated executor (`node:vm`) |
578
+ | `CodeExecutor.Direct` | Fast executor (`new Function`) |
579
+ | `CodeExecutor.Default` | Alias for `Vm` |
580
+ | `normalizeCode(code)` | Acorn-based code normalization |
581
+
582
+ ### Bridge
583
+
584
+ | Export | Description |
585
+ |--------|-------------|
586
+ | `effectToEffectFn(tool, runtime)` | Convert a tool to an Effect-returning function |
587
+ | `buildEffectFnTable(tools, runtime)` | Build a table of Effect-returning functions |
588
+ | `unwrapEffectTypes(value)` | Recursively unwrap Option/Either to plain values |
589
+
590
+ ### Discovery
591
+
592
+ | Export | Description |
593
+ |--------|-------------|
594
+ | `searchTools(catalog, query, detail?)` | Search tools by keyword with fuzzy matching |
595
+ | `listToolSummary(catalog, detail?)` | List all tools grouped by service |
596
+ | `generateDeclarations(tools)` | Generate TypeScript declarations from Schema AST |
597
+ | `astToTypeScript(ast)` | Convert a single Schema AST node to TypeScript |
598
+
599
+ ### Testing
600
+
601
+ | Export | Description |
602
+ |--------|-------------|
603
+ | `buildTestTool(...groups)` | Create a CodemodeTool for in-process testing |
604
+ | `buildInspector(...groups)` | Generate declarations string for snapshot testing |
605
+
606
+ ### Utilities
607
+
608
+ | Export | Description |
609
+ |--------|-------------|
610
+ | `sanitizeToolName(name)` | Sanitize a tool name to a valid JS identifier |
611
+
612
+ ### Types
613
+
614
+ | Export | Description |
615
+ |--------|-------------|
616
+ | `ToolShape` | Pure tool shape descriptor from `Codemode.tool()` |
617
+ | `ToolDescriptor` | Complete tool definition with schema, errors, execute |
618
+ | `ToolMiddleware` | Middleware function type `(invocation, next) => Effect` |
619
+ | `ToolInvocation` | Invocation context: `{ name, input }` |
620
+ | `ExecuteResult` | Result of sandbox execution: `{ result, error?, logs }` |
621
+ | `Executor` | Interface for code execution engines |
622
+ | `ExecutorOptions` | Options: `{ onLog?, allowNetwork?, timeoutMs? }` |
623
+
624
+ ## Why Effect in the sandbox?
625
+
626
+ Cloudflare's codemode uses async/await. That works, but leaves capabilities on the table:
627
+
628
+ | | async/await | Effect |
629
+ |---|---|---|
630
+ | Error handling | `try/catch` guessing | `Effect.catchTag("NotFoundError", ...)` with typed errors |
631
+ | Concurrency | `Promise.all` | `Effect.all([...], { concurrency: 5 })` with structured semantics |
632
+ | Resilience | Manual retry loops | `Effect.retry({ schedule: Schedule.exponential("1s") })` |
633
+ | Timeout | `Promise.race` hacks | `Effect.timeout("10 seconds")` |
634
+ | Type generation | Zod -> JSON Schema -> TypeScript | Schema AST -> TypeScript (zero conversion, richer annotations) |
635
+ | Integration | Conversion layer needed | Your `Context.Tag` + `Layer` + `Schema` _are_ the tool definitions |
636
+
637
+ If your services already use Effect, there's no conversion layer. Your schemas, services, and error types flow directly into the LLM's typed SDK.
638
+
639
+ ## License
640
+
641
+ MIT
@@ -0,0 +1,46 @@
1
+ import { Effect, Runtime } from "effect";
2
+ import type { ToolDescriptor, ToolMiddleware } from "./types";
3
+ /**
4
+ * Recursively unwraps Effect tagged types (Option, Either) into plain
5
+ * JSON-friendly values. Uses a WeakSet to safely handle circular references.
6
+ *
7
+ * - Option<A>: Some → unwrapped value, None → null
8
+ * - Either<L, R>: Right → unwrapped right value, Left → { _tag: "Left", left: ... }
9
+ * - Arrays: each element recursively unwrapped
10
+ * - Plain objects: each value recursively unwrapped
11
+ */
12
+ export declare const unwrapEffectTypes: (value: unknown, seen?: WeakSet<object> | undefined) => unknown;
13
+ export interface EffectToAsyncFnOptions {
14
+ /** Whether to validate output against the tool's outputSchema (default: true) */
15
+ readonly validateOutput?: boolean | undefined;
16
+ /** Middleware to wrap around each tool execution */
17
+ readonly middleware?: ReadonlyArray<ToolMiddleware> | undefined;
18
+ }
19
+ /**
20
+ * Converts a single Effect-based tool into a plain async function
21
+ * using a captured Effect Runtime. Unwraps Effect types (Option, Either)
22
+ * from the return value and retries on rate limit errors.
23
+ */
24
+ export declare const effectToAsyncFn: <R>(tool: ToolDescriptor, runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => (rawInput: unknown) => Promise<unknown>;
25
+ /**
26
+ * Builds a table of plain async functions from an array of tool descriptors
27
+ * and a captured Effect Runtime.
28
+ */
29
+ export declare const buildFnTable: <R>(tools: readonly ToolDescriptor[], runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => Record<string, (...args: unknown[]) => Promise<unknown>>;
30
+ /**
31
+ * Converts a single Effect-based tool into a function that returns an Effect.
32
+ * The returned Effect decodes input, calls the service, unwraps Effect types,
33
+ * and keeps errors in the Effect error channel (no throwing).
34
+ *
35
+ * Rate limit errors are retried using Effect.retry with exponential backoff.
36
+ */
37
+ export declare const effectToEffectFn: <R>(tool: ToolDescriptor, runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => (rawInput: unknown) => Effect.Effect<unknown, unknown, never>;
38
+ /**
39
+ * Builds a table of Effect-returning functions from an array of tool
40
+ * descriptors and a captured Effect Runtime.
41
+ *
42
+ * Each function in the table takes an input and returns an
43
+ * `Effect<unknown, unknown>` — errors stay in the Effect error channel.
44
+ */
45
+ export declare const buildEffectFnTable: <R>(tools: readonly ToolDescriptor[], runtime: Runtime.Runtime<R>, options?: EffectToAsyncFnOptions | undefined) => Record<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, never>>;
46
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAQ,OAAO,EAAoB,MAAM,QAAQ,CAAA;AAGhE,OAAO,KAAK,EAAE,cAAc,EAAkB,cAAc,EAAE,MAAM,SAAS,CAAA;AAM7E;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,iEA8C7B,CAAA;AAkDD,MAAM,WAAW,sBAAsB;IACrC,iFAAiF;IACjF,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC7C,oDAAoD;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,CAAA;CAChE;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,6IAyFhC,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,0KAe7B,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,mKAiDjC,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAAI,CAAC,gMAcnC,CAAA"}