History

server

Rhys Sullivan committed 9 months ago

Commit e00332d1 parent ef9b87520 files · +1670 −728Browse files

Unified · Split

20 files
  1. PLUGIN_ARCHITECTURE.md +662 −347
  2. apps/discord-bot/package.json +2 −0
  3. apps/discord-bot/src/agent-loop.ts +1 −1
  4. apps/discord-bot/src/format-response.test.ts +9 −82
  5. apps/discord-bot/src/format-response.ts +4 −259
  6. apps/discord-bot/src/gateway-client.ts +35 −0
  7. apps/discord-bot/src/index.tsx +7 −30
  8. apps/gateway/package.json +7 −1
  9. apps/gateway/src/agent-loop.ts +199 −0
  10. apps/gateway/src/anthropic-auth.ts +133 −0
  11. apps/gateway/src/anthropic-provider.ts +284 −0
  12. apps/gateway/src/calendar-store.ts +32 −0
  13. apps/gateway/src/code-typecheck.ts +87 −0
  14. apps/gateway/src/index.ts +70 −6
  15. apps/gateway/src/tools.ts +22 −0
  16. bun.lock +73 −2
  17. package.json +17 −0
  18. packages/core/package.json +1 −0
  19. packages/core/src/agent-rpc.ts +24 −0
  20. packages/core/src/index.ts +1 −0
PLUGIN_ARCHITECTURE.md+662−347
@@ -15,30 +15,263 @@ We need a plugin system that makes tools **installable, discoverable, and config · 14 unmodified lines
1515
1616 ---
1717
18+## Reference Analysis
19+
20+We studied two production systems before designing this:
21+
22+### OpenCode (`.reference/opencode/`)
23+
24+OpenCode's approach is the primary inspiration. Key patterns worth adopting:
25+
26+1. **Convention directories**: `.opencode/tool/`, `.opencode/plugin/`, `.opencode/skill/` — drop a `.ts` file in and it just works. No manifest file needed for simple tools.
27+2. **Auto-dependency management**: When `.opencode/` contains tools or plugins, OpenCode auto-generates a `package.json` with `@opencode-ai/plugin` and runs `bun install`. Zero friction.
28+3. **Plugin = function that returns hooks**: `Plugin = (input: PluginInput) => Promise<Hooks>`. Clean, no class ceremony.
29+4. **Tool definition via SDK**: `tool({ description, args: z.object({...}), execute(args) })` — zod schema inline, description as a string. Simple.
30+5. **File naming = tool ID**: Default export of `github-pr-search.ts` becomes tool `github-pr-search`. Named exports become `filename_exportname`.
31+6. **Multiple config sources**: Global (`~/.config/opencode/`), home (`~/.opencode/`), project (`.opencode/`), remote (`.well-known/opencode`). Deep merge with priority ordering.
32+7. **Bun-native loading**: Uses `import()` directly (Bun handles TypeScript). Also uses `Bun.Glob` for file discovery.
33+8. **Skills as Markdown**: `SKILL.md` files with YAML frontmatter. Separate from tools — skills are context/instructions, tools are callable functions.
34+
35+### OpenClaw (`.context/openclaw/`)
36+
37+More complex but some patterns worth noting:
38+
39+1. **jiti for TypeScript loading**: Works across Node and Bun, handles SDK aliasing at import time.
40+2. **Plugin manifest (`openclaw.plugin.json`)**: JSON Schema for config validation without executing plugin code.
41+3. **Layered precedence**: Config > Workspace > Global > Bundled, with first-match-wins deduplication.
42+4. **Registration API pattern**: `register(api)` where `api.registerTool()`, `api.registerHook()`, etc. Very extensible but heavyweight for MVP.
43+
44+### What We're Taking
45+
46+From OpenCode:
47+- Convention directories (`.openassistant/tools/`, `.openassistant/plugins/`)
48+- File-as-tool pattern (drop `.ts` in `tools/`, it just works)
49+- Bun-native `import()` for module loading
50+- Zod schemas on tool definitions (args + description)
51+- Auto-dependency management for `.openassistant/` directories
52+- Simple plugin function pattern
53+
54+From OpenClaw:
55+- Plugin manifest for config schema validation (but optional, not required)
56+- Config interpolation (`${ENV_VAR}`) for secrets
57+
58+Avoiding from both:
59+- OpenClaw's heavyweight registration API (too many `registerX()` methods for MVP)
60+- Over-engineered config merging (keep it simple: project > global)
61+
62+---
63+
1864 ## Design Principles
1965
20−1. **Config-file-driven.** One config file is the source of truth for what's loaded.
21−2. **No runtime magic.** Discovery is explicit scan, not auto-import. No decorators, no file-convention routing.
66+1. **Convention over configuration.** Drop a `.ts` file in `tools/`, it becomes a tool. No manifest needed for simple cases.
67+2. **Config when you need it.** Full plugins with config schemas exist for complex integrations, but aren't required.
2268 3. **Tools are the plugin unit.** MVP plugins = tool packages. Hooks, channels, services come later.
23−4. **TypeScript-native.** Plugins are authored in TypeScript, loaded via `jiti` (or Bun's native loader). No build step required for plugin authors.
24−5. **Monorepo-friendly.** Works whether your plugin is a workspace package, a path on disk, or an npm install.
25−6. **Minimal surface.** Plugin API is `defineTool()` + a manifest. That's it for MVP.
69+4. **TypeScript-native.** Bun loads `.ts` directly. No build step. No jiti (Bun-only for MVP).
70+5. **Monorepo-friendly.** Works whether your tool is a file in `.openassistant/tools/`, a workspace package, or an npm install.
71+6. **Zod schemas are the contract.** Input schemas enable type declaration generation, validation, and documentation — all from one source.
72+7. **Effect is an internal.** Tool authors never see Effect. The `run` function is a plain `async` function. The core runtime wraps it in Effect internally.
73+8. **Approval is the only policy knob.** No `kind`/`risk`/`type` classification. The tool author says `"auto"` or `"required"`. Operators can override via config.
74+
75+---
76+
77+## `defineTool()` — The Only API
78+
79+There's one function to define a tool: `defineTool()`. No tiers, no `tool()` vs `defineTool()` split.
80+
81+```ts
82+import { defineTool, z } from "@openassistant/sdk";
83+
84+export default defineTool({
85+ description: "Get current weather for a location",
86+ approval: "auto",
87+ args: z.object({
88+ location: z.string().describe("City name or coordinates"),
89+ }),
90+ run: async (input) => {
91+ const res = await fetch(`https://wttr.in/${input.location}?format=j1`);
92+ return res.json();
93+ },
94+});
95+```
96+
97+That's it. `run` is an async function. No Effect. No special return types. Throw to fail, return to succeed.
98+
99+### Full Signature
100+
101+```ts
102+function defineTool<TArgs extends z.ZodRawShape>(options: {
103+ /** Human description of what this tool does (shown to the LLM) */
104+ description: string;
105+ /** Whether the tool auto-runs or requires human approval before execution */
106+ approval: "auto" | "required";
107+ /** Zod schema for the input arguments */
108+ args: z.ZodObject<TArgs>;
109+ /** The tool implementation. Just an async function. */
110+ run: (input: z.infer<z.ZodObject<TArgs>>) => Promise<unknown>;
111+ /** Optional: format the input for the approval UI / receipt preview */
112+ previewInput?: (input: z.infer<z.ZodObject<TArgs>>) => string;
113+ /** Optional: format the output for the receipt preview */
114+ previewOutput?: (output: unknown) => string;
115+}): ToolDefinition
116+```
117+
118+`approval` is the only field that controls execution policy at the tool level. There's no `kind` / `risk` classification — the tool author decides whether their tool needs human sign-off or not. Operators can override this via config/policy later (force approval on auto tools, auto-approve required tools for trusted contexts, deny tools entirely).
119+
120+### What Happens Internally
121+
122+The core runner (`packages/core/src/codemode/runner.ts`) wraps the plain async `run` function in Effect at materialization time — in `createToolInvoker()`. Plugin authors never know or care.
123+
124+```ts
125+// Inside createToolInvoker (core internal):
126+const execution = await Effect.runPromiseExit(
127+ Effect.tryPromise(() => definition.run(validatedInput))
128+);
129+```
130+
131+The current `defineTool()` in core takes `run: (input) => Effect.Effect<T, E>`. That signature changes to `run: (input) => Promise<T>`. The core runner wraps it. The existing `tools.ts` in discord-bot updates accordingly (just remove the `Effect.sync()` / `Effect.tryPromise()` wrappers from tool definitions).
132+
133+### Before and After
134+
135+**Before** (current `apps/discord-bot/src/tools.ts`):
136+```ts
137+import { defineTool } from "@openassistant/core";
138+import { Effect } from "effect";
139+
140+defineTool({
141+ kind: "write",
142+ approval: "required",
143+ run: (input: { title: string; startsAt: string }) =>
144+ Effect.sync(() => calendarStore.update(input)), // Effect leaks into authoring
145+ previewInput: (input) => `${input.title} @ ${input.startsAt}`,
146+});
147+```
148+
149+**After** (new API):
150+```ts
151+import { defineTool, z } from "@openassistant/sdk";
152+
153+defineTool({
154+ description: "Update a calendar event",
155+ approval: "required",
156+ args: z.object({
157+ title: z.string(),
158+ startsAt: z.string(),
159+ notes: z.string().optional(),
160+ }),
161+ run: async (input) => calendarStore.update(input),
162+ previewInput: (input) => `${input.title} @ ${input.startsAt}`,
163+});
164+```
165+
166+Effect is gone from the authoring surface. `kind` is gone — `approval` is the only policy field. The `args` zod schema replaces the inline TypeScript type annotation on `run`'s parameter. `description` is now required.
167+
168+---
169+
170+## Where `defineTool()` Lives
171+
172+Two packages, two roles:
173+
174+| Package | Role | Contains |
175+|---------|------|----------|
176+| `packages/core` | **Runtime engine** | `ToolDefinition` type, `createCodeModeRunner()`, materialization, receipts, approval flow. Effect lives here. |
177+| `packages/sdk` | **Authoring surface** | `defineTool()` function, `z` re-export, `PluginContext` type. This is what plugin authors import. No Effect. |
178+
179+The SDK's `defineTool()` creates a `ToolDefinition` object (with `_tag: "ToolDefinition"`). The core runtime consumes it. The SDK doesn't import Effect at all — it's a thin layer that produces the right shape.
180+
181+```ts
182+// packages/sdk/src/define-tool.ts
183+import { z } from "zod";
184+
185+export interface ToolDefinition {
186+ readonly _tag: "ToolDefinition";
187+ readonly description: string;
188+ readonly approval: "auto" | "required";
189+ readonly args: z.ZodObject<any>;
190+ readonly run: (input: unknown) => Promise<unknown>;
191+ readonly previewInput?: (input: any) => string;
192+ readonly previewOutput?: (output: unknown) => string;
193+}
194+
195+export function defineTool<TArgs extends z.ZodRawShape>(options: {
196+ description: string;
197+ approval: "auto" | "required";
198+ args: z.ZodObject<TArgs>;
199+ run: (input: z.infer<z.ZodObject<TArgs>>) => Promise<unknown>;
200+ previewInput?: (input: z.infer<z.ZodObject<TArgs>>) => string;
201+ previewOutput?: (output: unknown) => string;
202+}): ToolDefinition {
203+ return {
204+ _tag: "ToolDefinition",
205+ description: options.description,
206+ approval: options.approval,
207+ args: options.args,
208+ run: options.run,
209+ previewInput: options.previewInput,
210+ previewOutput: options.previewOutput,
211+ };
212+}
213+```
214+
215+The core's `createToolInvoker()` then does:
216+1. Parse input through `definition.args` (zod validation)
217+2. Call `definition.run(validatedInput)`
218+3. Wrap in Effect for receipt tracking, approval flow, etc.
26219
27220 ---
28221
29−## What Is a Plugin?
222+## Standalone Tools vs Full Plugins
223+
224+### Standalone Tools (Zero Friction)
225+
226+A tool is a single `.ts` file in a convention directory. No `package.json`, no manifest.
227+
228+```
229+.openassistant/
230+ tools/
231+ weather.ts
232+ github-issues.ts
233+```
234+
235+**`weather.ts`**:
236+```ts
237+import { defineTool, z } from "@openassistant/sdk";
238+
239+export default defineTool({
240+ description: "Get current weather for a location",
241+ approval: "auto",
242+ args: z.object({
243+ location: z.string().describe("City name or coordinates"),
244+ }),
245+ run: async (input) => {
246+ const res = await fetch(`https://wttr.in/${input.location}?format=j1`);
247+ return res.json();
248+ },
249+});
250+```
251+
252+Rules:
253+- **Default export** → tool ID = filename without extension (`weather`)
254+- **Named exports** → tool ID = `filename.exportName` (`github_issues.list`)
30255
31−A plugin is a directory (or npm package) containing:
256+### Full Plugins (Config + Multiple Tools + Dependencies)
257+
258+A plugin is a directory with `package.json` (has `"openassistant"` key) and optionally `openassistant.json` for config schema.
32259
33260 ```
34−my-plugin/
35− package.json # Must have "openassistant" key
36− openassistant.json # Plugin manifest (metadata, config schema)
37− index.ts # Entry point, default exports a ToolTree or register function
261+.openassistant/
262+ plugins/
263+ posthog/
264+ package.json
265+ openassistant.json # Optional: config schema
266+ index.ts
38267 ```
39268
40−### `package.json` (minimal)
269+Or as an npm package:
270+```bash
271+bun add @openassistant/plugin-posthog
272+```
41273
274+**`package.json`** (minimal):
42275 ```json
43276 {
44277 "name": "@myorg/oa-posthog",
@@ -55,19 +288,12 @@ my-plugin/ · 10 unmodified lines
55288 }
56289 ```
57290
58−The `"openassistant"` key is the marker. It tells the loader this is a plugin, and where to find the entry point.
59−
60−- `entry` defaults to `./index.ts` (then `./index.js`, `./src/index.ts`).
61−- Plugin-specific dependencies go in `dependencies`. `@openassistant/sdk` is a `peerDependency` (the host provides it at load time).
62−
63−### `openassistant.json` (plugin manifest)
64−
291+**`openassistant.json`** (optional, for config validation):
65292 ```json
66293 {
67294 "id": "posthog",
68295 "name": "PostHog Analytics",
69296 "description": "Read analytics and create monitors via PostHog API.",
70− "version": "0.1.0",
71297 "configSchema": {
72298 "type": "object",
73299 "required": ["apiKey", "projectId"],
@@ -81,457 +307,545 @@ The `"openassistant"` key is the marker. It tells the loader this is a plugin, a · 7 unmodified lines
81307 }
82308 ```
83309
84−- `id` is the unique namespace. Tool paths become `posthog.analytics.getVisitors` etc.
85−- `configSchema` is JSON Schema. Validated at load time against the user's config.
86−- If no `openassistant.json` exists, the loader reads `id` from `package.json` name (strip scope) and config schema is treated as empty (no config required).
87−
88−### Entry Module
89−
90−Two supported patterns:
91−
92−**Pattern A: Export a ToolTree directly (simple case)**
93−
94−```ts
95−import { defineTool } from "@openassistant/sdk";
96−import { Effect } from "effect";
97−
98−export default {
99− analytics: {
100− getVisitors: defineTool({
101− id: "posthog.analytics.getVisitors",
102− kind: "read",
103− approval: "auto",
104− run: (input: { projectId: string }) =>
105− Effect.tryPromise(() => fetch(`...`).then(r => r.json())),
106− }),
107− },
108−} satisfies ToolTree;
109−```
110−
111−**Pattern B: Export a register function (needs config/context)**
112−
310+**`index.ts`** (plugin entry):
113311 ```ts
114−import { defineTool, type PluginContext, type ToolTree } from "@openassistant/sdk";
115−import { Effect } from "effect";
312+import { defineTool, z, type PluginContext, type ToolTree } from "@openassistant/sdk";
313+import { PostHogClient } from "posthog-node";
116314
117315 export default function register(ctx: PluginContext): ToolTree {
118− const client = new PostHogClient(ctx.config.apiKey, ctx.config.host);
316+ const client = new PostHogClient(ctx.config.apiKey as string);
119317
120318 return {
121319 analytics: {
122320 getVisitors: defineTool({
123− kind: "read",
321+ description: "Get visitor count for a website",
124322 approval: "auto",
125− run: (input: { website: string }) =>
126− Effect.tryPromise(() => client.getInsight(input.website)),
323+ args: z.object({ website: z.string() }),
324+ run: async (input) => client.getInsight(input.website),
127325 }),
128326 },
129327 monitor: {
130328 createThreshold: defineTool({
131− kind: "write",
329+ description: "Create a threshold alert for visitor count",
132330 approval: "required",
133− run: (input: { website: string; threshold: number }) =>
134− Effect.tryPromise(() => client.createAlert(input)),
331+ args: z.object({ website: z.string(), threshold: z.number() }),
332+ run: async (input) => client.createAlert(input),
135333 }),
136334 },
137335 };
138336 }
139337 ```
140338
141−`PluginContext` provides:
142−- `config`: validated plugin config from the user's config file.
143−- `logger`: scoped logger.
144−- `dataDir`: per-plugin persistent data directory (`~/.config/openassistant/data/<pluginId>/`).
339+Same `defineTool()`, same API. The only difference is the plugin exports a function that receives config via `PluginContext`, instead of exporting tools directly.
145340
146341 ---
147342
148343 ## Config File
149344
150−Location: `~/.config/openassistant/config.json` (or `OPENASSISTANT_CONFIG_PATH` env var).
345+### Location Resolution
151346
152−For monorepo users: `.openassistant/config.json` in the project root (detected by walking up from cwd).
347+Config is found by scanning these locations (higher priority wins):
153348
154−Resolution order:
155−1. Project-local: `<project>/.openassistant/config.json`
156−2. Global: `~/.config/openassistant/config.json`
157−3. Merged (project overrides global).
349+| Priority | Location | Env Override |
350+|----------|----------|--------------|
351+| 1 (highest) | `OPENASSISTANT_CONFIG` env var (path to file) | — |
352+| 2 | `<project>/.openassistant/config.json` | — |
353+| 3 (lowest) | `~/.config/openassistant/config.json` | `OPENASSISTANT_CONFIG_DIR` |
158354
159−```json
355+Project root is detected by walking up from cwd looking for `.openassistant/` directory or `package.json`.
356+
357+When both project and global exist, they're deep-merged (project overrides global).
358+
359+### Config Shape
360+
361+```jsonc
160362 {
161− "plugins": {
162− "paths": [
163− "./tools/posthog",
164− "./tools/github",
165− "/absolute/path/to/custom-plugin"
166− ],
167− "npm": [
168− "@openassistant/plugin-github",
169− "@openassistant/plugin-posthog@^0.2.0"
170− ],
171− "config": {
172− "posthog": {
173− "enabled": true,
174− "apiKey": "${POSTHOG_API_KEY}",
175− "projectId": "12345",
176− "host": "https://us.posthog.com"
177− },
178− "github": {
179− "enabled": true,
180− "token": "${GITHUB_TOKEN}"
181− }
363+ // Plugin specifiers — npm packages or file:// URLs
364+ "plugins": [
365+ "@openassistant/plugin-posthog",
366+ "@openassistant/plugin-github@^0.2.0",
367+ "file:///absolute/path/to/plugin",
368+ "./relative/path/to/plugin"
369+ ],
370+
371+ // Per-plugin configuration
372+ "config": {
373+ "posthog": {
374+ "enabled": true,
375+ "apiKey": "${POSTHOG_API_KEY}",
376+ "projectId": "12345",
377+ "host": "https://us.posthog.com"
378+ },
379+ "github": {
380+ "enabled": true,
381+ "token": "${GITHUB_TOKEN}"
182382 }
183383 },
384+
385+ // Auth profiles
184386 "auth": {
185387 "anthropic": {
186388 "type": "token",
187389 "profileOrder": ["manual", "env"]
188390 }
391+ },
392+
393+ // Tool-level overrides
394+ "tools": {
395+ "weather": true, // enable (default)
396+ "dangerous-tool": false // disable
189397 }
190398 }
191399 ```
192400
193−### Plugin Sources (Discovery Origins)
401+- **`plugins` is a flat array** (like OpenCode). The loader auto-detects whether a specifier is an npm package, a path, or a URL.
402+- **`config` is top-level**, keyed by plugin ID.
403+- **`tools` is a simple enable/disable map.** Individual tools can be toggled without touching plugin config.
404+- **`${ENV_VAR}` interpolation** for secrets. Only in string values. No shell expansion.
194405
195−Plugins are discovered from these origins, in priority order:
406+---
196407
197−| Origin | Location | Use Case |
198−|--------|----------|----------|
199−| `config.plugins.paths` | Explicit paths from config | Monorepo workspace tools, local dev |
200−| `workspace` | `<project>/.openassistant/plugins/` | Project-scoped plugins dropped into a convention dir |
201−| `global` | `~/.config/openassistant/plugins/` | User-global plugins (installed via CLI) |
202−| `npm` | `config.plugins.npm` entries resolved from `node_modules` | npm-installed plugins |
203−| `bundled` | `<openassistant-package>/plugins/` | Shipped with the core package |
408+## Discovery and Loading
204409
205−### Plugin Config
410+### Convention Directories
206411
207−Config values support `${ENV_VAR}` interpolation so secrets don't live in config files.
412+These directories are auto-scanned for tools and plugins:
208413
209−Per-plugin config is validated against the plugin's `configSchema` at load time. If validation fails, the plugin is skipped with a warning (not a crash).
414+| Directory | What's Scanned |
415+|-----------|----------------|
416+| `<project>/.openassistant/tools/` | Standalone `.ts`/`.js` files |
417+| `<project>/.openassistant/plugins/` | Plugin directories with `package.json` |
418+| `~/.config/openassistant/tools/` | Global standalone tools |
419+| `~/.config/openassistant/plugins/` | Global plugin directories |
210420
211−`enabled: false` in a plugin's config block disables it without removing its config.
421+### Auto-Dependency Management
212422
213−---
423+When the `.openassistant/` directory contains tools or plugins, the loader:
424+
425+1. Checks for `package.json` in `.openassistant/`.
426+2. If missing, creates one with `@openassistant/sdk` as a dependency.
427+3. Runs `bun install` if `node_modules` doesn't exist or `@openassistant/sdk` is missing.
428+
429+Users can just drop a `.ts` file in `tools/` and it works — the SDK dependency is auto-managed.
430+
431+```json
432+// Auto-generated .openassistant/package.json
433+{
434+ "private": true,
435+ "type": "module",
436+ "dependencies": {
437+ "@openassistant/sdk": "^0.1.0"
438+ }
439+}
440+```
214441
215−## Discovery and Loading Pipeline
442+### Full Pipeline
216443
217444 ```
218445 1. RESOLVE CONFIG
219− - Find config file (project-local, then global)
220− - Parse and merge
221−
222−2. DISCOVER PLUGINS
223− - Scan each origin (paths, workspace, global, npm, bundled)
224− - For each candidate directory:
225− a. Read package.json, check for "openassistant" key
226− b. Read openassistant.json (or derive defaults from package.json)
227− c. Deduplicate by plugin ID (first origin wins)
228−
229−3. FILTER
230− - Check enabled/disabled state from config
231− - Skip plugins with missing required config
232−
233−4. VALIDATE CONFIG
234− - Run JSON Schema validation on each plugin's config block
235− - Log warnings for invalid configs, skip those plugins
236−
237−5. LOAD MODULE
238− - Use jiti (or Bun native import) to load the TypeScript entry point
239− - Alias "@openassistant/sdk" to the host's SDK module
240−
241−6. RESOLVE EXPORT
242− - If default export is a function: call it with PluginContext -> get ToolTree
243− - If default export is a ToolTree: use directly
244− - Namespace the tree under the plugin ID
245−
246−7. MERGE TOOL TREES
247− - Combine all plugin ToolTrees into the master ToolTree
446+ - Find config file (env > project > global)
447+ - Parse JSONC with ${ENV_VAR} interpolation
448+ - Deep merge if multiple sources
449+
450+2. SCAN CONVENTION DIRECTORIES
451+ - .openassistant/tools/*.{ts,js} → standalone tool candidates
452+ - .openassistant/plugins/*/ → plugin candidates (must have package.json)
453+ - Same for global ~/.config/openassistant/
454+
455+3. RESOLVE PLUGIN SPECIFIERS
456+ - For each entry in config.plugins[]:
457+ a. npm package name → resolve from node_modules
458+ b. file:// URL → resolve absolute path
459+ c. Relative path → resolve from project root
460+
461+4. AUTO-INSTALL DEPENDENCIES
462+ - Ensure .openassistant/package.json exists with SDK dep
463+ - Run bun install if needed
464+
465+5. DEDUPLICATE
466+ - By plugin ID (from manifest) or tool filename
467+ - First-found wins (project > global > npm > bundled)
468+
469+6. LOAD MODULES
470+ - Use dynamic import() (Bun handles .ts natively)
471+ - For standalone tools: import file, extract default + named exports
472+ - For plugins: import entry, resolve function vs ToolTree export
473+
474+7. VALIDATE CONFIG
475+ - If plugin has openassistant.json with configSchema:
476+ validate config[pluginId] against JSON Schema
477+ - Skip with warning on validation failure
478+
479+8. VALIDATE INPUTS (at tool definition time)
480+ - Each tool has a zod args schema
481+ - At invocation time, parse input through schema before calling run
482+
483+9. BUILD TOOL TREE
484+ - Standalone tools: flat under their filename ID
485+ - Plugin tools: namespaced under plugin ID
486+ - Check tools config for enable/disable overrides
248487 - Detect ID collisions (error)
249488
250−8. GENERATE TYPE DECLARATIONS
251− - Walk the merged ToolTree
252− - Emit TypeScript ambient declarations for the codemode typechecker
253− - This replaces the hardcoded TOOL_DECLARATIONS string
489+10. GENERATE TYPE DECLARATIONS
490+ - Walk merged ToolTree
491+ - Derive TypeScript types from zod args schemas
492+ - This replaces the hardcoded TOOL_DECLARATIONS
254493
255−9. READY
256− - Pass merged ToolTree to createCodeModeRunner()
257− - Plugin tools are now available in the sandbox
494+11. READY
495+ - Pass merged ToolTree to createCodeModeRunner()
496+ - Pass declarations to typechecker
258497 ```
259498
260499 ---
261500
262501 ## The SDK Package (`@openassistant/sdk`)
263502
264−New package: `packages/sdk/`. This is the public API surface for plugin authors.
503+New package: `packages/sdk/`. This is the **only** package tool authors need. No Effect, no core internals.
265504
266−Exports:
267−- `defineTool()` (re-exported from core)
268−- `ToolTree`, `ToolDefinition`, `ToolKind`, `ToolApprovalMode` types
269−- `PluginContext` type
270−- `z` (re-export zod for schema definitions)
271−- `Effect` (re-export for handler authoring)
505+### Exports
272506
273507 ```ts
274508 // packages/sdk/src/index.ts
275−export { defineTool, type ToolTree, type ToolDefinition, type ToolKind, type ToolApprovalMode } from "@openassistant/core";
509+export { defineTool, type ToolDefinition, type ToolTree } from "./define-tool.js";
276510 export type { PluginContext } from "./plugin-context.js";
277511 export { z } from "zod";
278−export { Effect } from "effect";
279512 ```
280513
281−Plugin authors install `@openassistant/sdk` as a `peerDependency`. The host resolves it via aliasing at load time (same pattern as OpenClaw's jiti alias for `openclaw/plugin-sdk`).
514+That's it. Three things: `defineTool`, `PluginContext`, `z`.
515+
516+### What the SDK does NOT export
517+
518+- `Effect` — internal to core runtime
519+- `ToolCallReceipt` — internal to core runtime
520+- `ApprovalRequest` — internal to core runtime
521+- `createCodeModeRunner` — internal to core runtime
522+
523+---
524+
525+## Changes to Core
526+
527+### `ToolDefinition` (the shape)
528+
529+The `ToolDefinition` interface changes from Effect-based to plain async, and drops `kind`:
530+
531+```ts
532+// packages/core — BEFORE
533+export interface ToolDefinition<TInput = unknown, TOutput = unknown> {
534+ readonly _tag: "ToolDefinition";
535+ readonly kind: ToolKind; // "read" | "write"
536+ readonly approval: ToolApprovalMode; // "auto" | "required"
537+ readonly run: (input: TInput) => Effect.Effect<TOutput, unknown>;
538+ readonly previewInput?: (input: TInput) => string | undefined;
539+ readonly previewOutput?: (output: TOutput) => string | undefined;
540+}
541+
542+// packages/core — AFTER
543+export interface ToolDefinition {
544+ readonly _tag: "ToolDefinition";
545+ readonly description: string;
546+ readonly approval: "auto" | "required";
547+ readonly args: z.ZodObject<any>;
548+ readonly run: (input: unknown) => Promise<unknown>;
549+ readonly previewInput?: (input: any) => string | undefined;
550+ readonly previewOutput?: (output: unknown) => string | undefined;
551+}
552+```
553+
554+Changes:
555+1. `run` returns `Promise<unknown>` instead of `Effect.Effect<TOutput, unknown>`
556+2. `kind` removed — `approval` is the only policy field
557+3. `args` (zod schema) added — required
558+4. `description` added — required
559+5. Generic type parameters removed (zod handles type safety at the authoring site, core treats everything as `unknown` internally)
560+
561+### `createToolInvoker()` (the runtime)
562+
563+The invoker validates input via zod, then wraps the plain async `run` in Effect:
564+
565+```ts
566+// BEFORE: runs Effect directly, no input validation
567+const execution = await Effect.runPromiseExit(params.definition.run(input));
568+
569+// AFTER: validates with zod, wraps async function in Effect
570+const validated = params.definition.args.safeParse(input);
571+if (!validated.success) {
572+ // emit failed receipt with validation error, throw
573+}
574+const execution = await Effect.runPromiseExit(
575+ Effect.tryPromise(() => params.definition.run(validated.data))
576+);
577+```
578+
579+### `createCodeModeRunner()` (unchanged API)
580+
581+The runner still takes a `ToolTree` and a `requestApproval` callback. Still returns an Effect. The public contract of the runner doesn't change — only the internal `ToolDefinition` shape does.
582+
583+### Migration of existing tools
584+
585+`apps/discord-bot/src/tools.ts` drops Effect imports and uses the new API:
586+
587+```ts
588+// BEFORE
589+import { defineTool } from "@openassistant/core";
590+import { Effect } from "effect";
591+
592+calendar: {
593+ update: defineTool({
594+ kind: "write",
595+ approval: "required",
596+ run: (input: { title: string; startsAt: string }) =>
597+ Effect.sync(() => calendarStore.update(input)),
598+ }),
599+}
600+
601+// AFTER
602+import { defineTool, z } from "@openassistant/sdk";
603+
604+calendar: {
605+ update: defineTool({
606+ description: "Create or update a calendar event",
607+ approval: "required",
608+ args: z.object({
609+ title: z.string(),
610+ startsAt: z.string(),
611+ notes: z.string().optional(),
612+ }),
613+ run: async (input) => calendarStore.update(input),
614+ }),
615+}
616+```
617+
618+### Migration of `ToolCallReceipt`
619+
620+The `kind` field is also removed from `ToolCallReceipt`. Receipts track `approval` mode and `decision` — that's sufficient. The receipt shape becomes:
621+
622+```ts
623+export interface ToolCallReceipt {
624+ callId: string;
625+ toolPath: string;
626+ approval: "auto" | "required";
627+ decision: "auto" | "approved" | "denied";
628+ status: "succeeded" | "failed" | "denied";
629+ timestamp: string;
630+ inputPreview?: string;
631+ outputPreview?: string;
632+ error?: string;
633+}
634+```
282635
283636 ---
284637
285638 ## Type Declaration Generation
286639
287−The current `code-typecheck.ts` has a hardcoded `TOOL_DECLARATIONS` string. This needs to be generated dynamically from the merged ToolTree.
640+### The Problem
288641
289−Approach:
290−- After loading all plugins and merging the ToolTree, walk the tree.
291−- For each `ToolDefinition`, inspect the `run` function's parameter type (via the zod schema on the definition, which we add in the next iteration).
292−- Generate ambient declarations: `declare const tools: { calendar: { update(input: { title: string; startsAt: string; notes?: string }): Promise<unknown>; }; };`
293−- Pass this to the typechecker instead of the hardcoded string.
642+`code-typecheck.ts` currently has a hardcoded `TOOL_DECLARATIONS` string. When tools are dynamically loaded, we need to generate this.
294643
295−**MVP shortcut**: For MVP, require plugins to export a `declarations` string alongside their ToolTree. Long-term, derive from schemas.
644+### Approach: Derive from Zod Schemas
645+
646+Every tool now has a zod `args` schema. We walk the merged ToolTree, convert each schema to a TypeScript type string, and emit ambient declarations.
296647
297648 ```ts
298−// Plugin can optionally export declarations
299−export const declarations = `{
300− analytics: {
301− getVisitors(input: { projectId: string; website: string }): Promise<unknown>;
302− };
303− monitor: {
304− createThreshold(input: { website: string; threshold: number }): Promise<unknown>;
305− };
306−}`;
649+// Given:
650+defineTool({
651+ description: "Get weather",
652+ approval: "auto",
653+ args: z.object({
654+ location: z.string(),
655+ units: z.enum(["metric", "imperial"]).default("metric"),
656+ }),
657+ run: async (args) => { ... },
658+});
659+
660+// Generates:
661+// weather(input: { location: string; units?: "metric" | "imperial" }): Promise<unknown>
307662 ```
308663
309−The loader concatenates all plugin declaration fragments into a single `declare const tools: { ... }` block.
664+For the full merged tree:
665+```ts
666+declare const tools: {
667+ weather(input: { location: string; units?: "metric" | "imperial" }): Promise<unknown>;
668+ posthog: {
669+ analytics: {
670+ getVisitors(input: { website: string }): Promise<unknown>;
671+ };
672+ monitor: {
673+ createThreshold(input: { website: string; threshold: number }): Promise<unknown>;
674+ };
675+ };
676+};
677+```
310678
311679 ---
312680
313681 ## Monorepo User Experience
314682
315−### Scenario: User has a Bun monorepo
683+### Scenario 1: Quick tool (30 seconds to working)
316684
317−```
318−my-project/
319− .openassistant/
320− config.json
321− plugins/ # Convention dir for local plugins
322− posthog/
323− package.json
324− openassistant.json
325− index.ts
326− apps/
327− my-app/
328− packages/
329− my-lib/
685+```bash
686+mkdir -p .openassistant/tools
330687 ```
331688
332−`.openassistant/config.json`:
333−```json
334−{
335− "plugins": {
336− "config": {
337− "posthog": {
338− "apiKey": "${POSTHOG_API_KEY}",
339− "projectId": "12345"
340− }
341− }
342− }
343−}
689+Create `.openassistant/tools/weather.ts`:
690+```ts
691+import { defineTool, z } from "@openassistant/sdk";
692+
693+export default defineTool({
694+ description: "Get current weather",
695+ approval: "auto",
696+ args: z.object({ location: z.string() }),
697+ run: async ({ location }) => {
698+ const res = await fetch(`https://wttr.in/${location}?format=j1`);
699+ return res.json();
700+ },
701+});
344702 ```
345703
346−That's it. The convention directory is auto-scanned. Config is in one place.
704+Done. OpenAssistant auto-creates `package.json`, installs SDK, discovers the tool.
347705
348−### Scenario: User has tools as a workspace package
706+### Scenario 2: Workspace package plugin
349707
350708 ```
351709 my-project/
352710 .openassistant/
353711 config.json
354712 packages/
355− oa-tools/ # Workspace package with tools
356− package.json # has "openassistant" key
357− openassistant.json
358− src/
359− index.ts
713+ oa-posthog/
714+ package.json # has "openassistant" key
715+ openassistant.json # config schema
716+ index.ts
360717 ```
361718
362719 `.openassistant/config.json`:
363−```json
720+```jsonc
364721 {
365− "plugins": {
366− "paths": ["./packages/oa-tools"]
722+ "plugins": ["./packages/oa-posthog"],
723+ "config": {
724+ "posthog": {
725+ "apiKey": "${POSTHOG_API_KEY}",
726+ "projectId": "12345"
727+ }
367728 }
368729 }
369730 ```
370731
371−Explicit path reference. Clean, no magic.
372−
373−### Scenario: User installs from npm
732+### Scenario 3: npm package
374733
375734 ```bash
376735 bun add @openassistant/plugin-github
377736 ```
378737
379738 `.openassistant/config.json`:
380−```json
739+```jsonc
381740 {
382− "plugins": {
383− "npm": ["@openassistant/plugin-github"],
384− "config": {
385− "github": {
386− "token": "${GITHUB_TOKEN}"
387− }
388− }
741+ "plugins": ["@openassistant/plugin-github"],
742+ "config": {
743+ "github": { "token": "${GITHUB_TOKEN}" }
389744 }
390745 }
391746 ```
392747
393−The loader resolves `@openassistant/plugin-github` from `node_modules`.
394−
395−---
396−
397−## CLI Surface (Future)
398−
399−```bash
400−# Initialize config in current project
401−openassistant init
748+### Scenario 4: Multiple tools in one file
402749
403−# List discovered plugins and their status
404−openassistant plugins list
405−
406−# Validate plugin configs
407−openassistant plugins check
408−
409−# Install a plugin from npm to global plugins dir
410−openassistant plugins install @openassistant/plugin-github
750+`.openassistant/tools/github-issues.ts`:
751+```ts
752+import { defineTool, z } from "@openassistant/sdk";
411753
412−# Scaffold a new plugin
413−openassistant plugins create my-plugin
754+export const list = defineTool({
755+ description: "List GitHub issues",
756+ approval: "auto",
757+ args: z.object({
758+ repo: z.string(),
759+ state: z.enum(["open", "closed"]).default("open"),
760+ }),
761+ run: async ({ repo, state }) => { ... },
762+});
763+
764+export const create = defineTool({
765+ description: "Create a GitHub issue",
766+ approval: "required",
767+ args: z.object({
768+ repo: z.string(),
769+ title: z.string(),
770+ body: z.string().optional(),
771+ }),
772+ run: async ({ repo, title, body }) => { ... },
773+});
414774 ```
415775
416−Not needed for MVP. The config file + discovery is enough.
776+Tools become: `tools.github_issues.list(...)` and `tools.github_issues.create(...)`.
417777
418778 ---
419779
420780 ## Implementation Plan
421781
422−### Phase 1: Config + Discovery (Foundation)
782+### Phase 1: New `defineTool()` + SDK
423783
424−1. **Config loader** (`packages/core/src/config/`)
425− - Find config file (project-local walk-up, global fallback)
426− - Parse JSON with `${ENV_VAR}` interpolation
427− - Type-safe config shape with validation
428− - Export `resolveConfig(): OpenAssistantConfig`
784+1. **Change `ToolDefinition` in core** — `run` becomes `Promise`, drop `kind`, add `args` + `description`
785+2. **Update `createToolInvoker()`** — wrap async `run` in Effect internally, add zod validation
786+3. **Update `ToolCallReceipt`** — drop `kind` field
787+4. **Create `packages/sdk/`** — `defineTool()`, `z` re-export, `PluginContext` type
788+5. **Migrate `apps/discord-bot/src/tools.ts`** — use new API, drop Effect
789+6. **Type declaration generator** — walk ToolTree, emit declarations from zod schemas
429790
430−2. **Plugin discovery** (`packages/core/src/plugins/discovery.ts`)
431− - Scan each origin (paths, workspace dir, global dir)
432− - Read `package.json` "openassistant" key
433− - Read `openassistant.json` manifest
434− - Return `DiscoveredPlugin[]` with id, path, manifest, origin
791+### Phase 2: Config + Discovery
435792
436−3. **Plugin loader** (`packages/core/src/plugins/loader.ts`)
437− - Load TypeScript entry via `jiti` or dynamic `import()`
438− - Resolve export pattern (function vs ToolTree)
439− - Validate plugin config against schema
440− - Return `LoadedPlugin { id, toolTree, declarations? }`
793+6. **Config loader** (`packages/core/src/config/`)
794+ - Config file resolution (env > project > global)
795+ - JSONC parsing + `${ENV_VAR}` interpolation
796+ - Zod schema for config shape validation
441797
442−4. **Plugin registry** (`packages/core/src/plugins/registry.ts`)
443− - Merge all loaded plugin ToolTrees into master tree
444− - Namespace under plugin IDs
445− - Detect collisions
446− - Export `createPluginRegistry(config): { toolTree: ToolTree, declarations: string }`
798+7. **Tool/Plugin discovery** (`packages/core/src/plugins/`)
799+ - Scan convention directories (`tools/`, `plugins/`)
800+ - Resolve plugin specifiers (npm, file://, relative path)
801+ - Auto-dependency management for `.openassistant/`
802+ - Deduplicate by ID
447803
448−### Phase 2: SDK Package
449−
450−5. **Create `packages/sdk/`**
451− - Barrel export of `defineTool`, types, `PluginContext`, `z`, `Effect`
452− - `package.json` with proper `exports` map
453− - Will eventually be publishable to npm (remove `private: true`)
804+8. **Plugin loader** (`packages/core/src/plugins/loader.ts`)
805+ - Dynamic `import()` for `.ts` files
806+ - Resolve export pattern (function vs ToolTree vs individual tools)
807+ - Config validation against manifest schema
808+ - Build merged ToolTree + declarations
454809
455810 ### Phase 3: Wire Into Runtime
456811
457−6. **Update `apps/discord-bot/`**
458− - Replace hardcoded `createToolTree()` with `createPluginRegistry(config).toolTree`
459− - Replace hardcoded `TOOL_DECLARATIONS` with generated declarations
460− - Config-driven tool loading
461−
462−7. **Update `apps/gateway/`**
463− - Same plugin registry integration
812+9. **Update `apps/discord-bot/`** — use plugin registry instead of hardcoded tools
813+10. **Update `apps/gateway/`** — same
464814
465−### Phase 4: Developer Experience
815+### Phase 4: DX Polish
466816
467−8. **`openassistant init` command** (scaffold `.openassistant/config.json`)
468−9. **`openassistant plugins create` command** (scaffold plugin directory)
469−10. **Documentation** for plugin authors
817+11. **`openassistant init`** — scaffold `.openassistant/config.json`
818+12. **`openassistant tools create <name>`** — scaffold a tool file
819+13. **`openassistant plugins list`** — show discovered tools/plugins and their status
470820
471821 ---
472822
473−## Key Decisions to Make
474−
475−### 1. Module Loading Strategy
476−
477−**Option A: jiti** (like OpenClaw)
478−- Pros: Battle-tested, works in Node and Bun, handles TypeScript natively.
479−- Cons: Another dependency, may have edge cases with Effect imports.
480−
481−**Option B: Bun native `import()`**
482−- Pros: Zero deps, Bun handles TypeScript natively, fast.
483−- Cons: Only works in Bun runtime, may need `file://` URL construction, plugin resolution is less configurable.
484−
485−**Option C: Bun `import()` with fallback to jiti for Node compat**
486−- Pros: Best of both. Fast path for Bun, fallback for portability.
487−
488−**Recommendation: Option B for MVP (Bun-only).** We're already Bun-native. Use dynamic `import()` with path resolution. Add jiti as a fallback later if Node support matters.
489−
490−### 2. Where Does Config Live?
491−
492−**Recommendation:**
493−- Project-local: `.openassistant/config.json` (detected by walking up from cwd looking for `.openassistant/` or `package.json` with `"openassistant"` key).
494−- Global: `~/.config/openassistant/config.json`.
495−- Merge strategy: project-local overrides global (deep merge for `plugins.config`).
496−
497−### 3. Plugin ID Namespacing
498−
499−**Option A:** Plugin tools are auto-namespaced: `tools.<pluginId>.<toolName>`.
500−**Option B:** Plugin chooses its own namespace via manifest `id`.
501−**Option C:** Flat merge, plugin responsible for unique paths.
502−
503−**Recommendation: Option B.** The manifest `id` is the namespace. `tools.posthog.analytics.getVisitors`. The `id` is the top-level key in the merged tree.
504−
505−### 4. Schema on Tool Definitions
506−
507−Currently `defineTool()` doesn't take an input schema -- the `run` function signature is the contract. For type declaration generation, we need schemas.
508−
509−**Recommendation:** Add optional `input` schema (zod) to `defineTool()`. Not required for MVP (use exported `declarations` string as bridge), but strongly encouraged.
510−
511−```ts
512−defineTool({
513− kind: "read",
514− approval: "auto",
515− input: z.object({ projectId: z.string() }), // NEW
516− run: (input) => Effect.tryPromise(() => ...),
517−})
518−```
519−
520−### 5. Hot Reload
521−
522−**MVP: No.** Restart to pick up plugin changes. Gateway already supports `--hot` via Bun, which covers dev workflow. True hot-reload of plugin registry is Phase 2+.
823+## Key Decisions (Resolved)
824+
825+| Decision | Choice | Rationale |
826+|----------|--------|-----------|
827+| Tool authoring API | Plain async `run` function | Effect is a runtime internal. Tool authors shouldn't know it exists. |
828+| Tool policy | `approval: "auto" \| "required"` only | No `kind`/`risk` classification. Tool author sets approval default. Operators can override via config. |
829+| Module loading | Bun native `import()` | We're Bun-only. Zero deps. Bun handles .ts natively. |
830+| Config format | JSONC | Comments are essential for config files. |
831+| Config location | `.openassistant/config.json` | Follows OpenCode's `.opencode/` pattern. |
832+| Plugin specifiers | Flat array (like OpenCode) | Simpler than `{ paths, npm }`. Auto-detect type from specifier. |
833+| Tool schemas | Required zod `args` on every tool | Enables type declaration gen, validation, and docs from one source. |
834+| Namespacing | Plugin ID from manifest or dirname | Plugin tools namespaced automatically. Standalone tools are flat. |
835+| Hot reload | No (MVP) | Bun `--hot` covers dev. True registry reload is Phase 2+. |
836+| SDK package | Separate (`packages/sdk/`) | Clean boundary. No Effect dependency for tool authors. |
523837
524838 ---
525839
526840 ## Security Considerations
527841
528−1. **Plugins run in the same process.** No sandbox between plugins and the gateway. This is intentional for MVP -- the sandbox is between the LLM-generated code and the tool implementations, not between plugins.
842+1. **Plugins run in the same process.** No sandbox between plugins and the gateway. The sandbox boundary is between LLM-generated code and tool implementations, not between plugins.
529843
530844 2. **Config interpolation** only supports `${ENV_VAR}` syntax. No shell expansion, no nested interpolation, no code execution.
531845
532−3. **Plugin code is trusted.** If a user puts a plugin in their config, they trust it. We don't scan for malicious code (unlike OpenClaw's warning-only scan).
846+3. **Plugin code is trusted.** If a user puts a plugin in their config, they trust it. No malicious code scanning.
533847
534−4. **Secrets in config** should use env var interpolation. We should warn (not block) if literal API keys appear in config files.
848+4. **Secrets in config** should use env var interpolation. We log a warning (not block) if literal API keys appear in config files.
535849
536850 ---
537851
@@ -543,36 +857,37 @@ packages/ · 5 unmodified lines
543857 src/
544858 index.ts
545859 codemode/
546− runner.ts # Existing, unchanged
860+ runner.ts # UPDATED: ToolDefinition uses Promise, adds zod validation
861+ runner.test.ts
547862 config/
548− loader.ts # NEW: config file resolution + parsing
549− types.ts # NEW: config schema types
550− interpolation.ts # NEW: ${ENV_VAR} expansion
863+ loader.ts # NEW: config file resolution + parsing
864+ types.ts # NEW: config shape (Zod schema)
865+ interpolation.ts # NEW: ${ENV_VAR} expansion
551866 plugins/
552− discovery.ts # NEW: scan origins for plugin candidates
553− loader.ts # NEW: load plugin TypeScript modules
554− manifest.ts # NEW: parse/validate openassistant.json
555− registry.ts # NEW: merge ToolTrees, generate declarations
556− types.ts # NEW: DiscoveredPlugin, LoadedPlugin, etc.
867+ discovery.ts # NEW: scan convention dirs + resolve specifiers
868+ loader.ts # NEW: dynamic import + export resolution
869+ manifest.ts # NEW: parse/validate openassistant.json
870+ registry.ts # NEW: merge ToolTrees, deduplicate, enable/disable
871+ declarations.ts # NEW: generate TypeScript declarations from zod schemas
872+ auto-install.ts # NEW: auto-create package.json + bun install
873+ types.ts # NEW: DiscoveredPlugin, LoadedPlugin, etc.
557874 sdk/
558− package.json # NEW: @openassistant/sdk
875+ package.json # NEW: @openassistant/sdk
559876 src/
560− index.ts # NEW: public API barrel
561− plugin-context.ts # NEW: PluginContext type
877+ index.ts # NEW: exports defineTool, z, PluginContext
878+ define-tool.ts # NEW: defineTool() — creates ToolDefinition, no Effect
879+ plugin-context.ts # NEW: PluginContext type
562880 ```
563881
564882 ---
565883
566884 ## Open Questions
567885
568−1. **Should the SDK be a separate package or an export path from core?**
569− Leaning separate package (`packages/sdk/`) for clean dependency boundary. Plugin authors only need the SDK, not core internals.
570−
571−2. **How do we handle plugin dependencies at install time?**
572− For workspace/path plugins: they manage their own deps. For npm plugins: deps come with the package. For global install: we may need `bun install` in the plugin dir (like OpenClaw does `npm install --omit=dev`).
886+1. **Do we validate tool inputs at runtime?**
887+ Yes. Parse input through zod schema before calling `run`. This catches bad LLM-generated inputs early with clear error messages in receipts.
573888
574−3. **Do we need a plugin lockfile?**
575− Probably not for MVP. Config file is declarative. `bun.lock` in the host project handles npm-sourced plugins.
889+2. **How do we handle tools that need persistent state?**
890+ `PluginContext.dataDir` gives plugins a persistent directory. Not needed for standalone tools in MVP.
576891
577−4. **Should plugins be able to register things other than tools?**
578− Not for MVP. But the `register(ctx)` function pattern leaves the door open for `ctx.registerHook()`, `ctx.registerCommand()`, etc. later.
892+3. **Should we support `openassistant.jsonc` (comments)?**
893+ Yes. Both `config.json` and `config.jsonc` should be accepted.
apps/discord-bot/package.json+2−0
@@ -13,6 +13,8 @@ · 12 unmodified lines
1313 },
1414 "dependencies": {
1515 "@ai-sdk/anthropic": "^2.0.0",
16+ "@effect/platform": "latest",
17+ "@effect/rpc": "latest",
1618 "@openassistant/core": "workspace:*",
1719 "@openassistant/reacord": "workspace:*",
1820 "ai": "^6.0.73",
apps/discord-bot/src/agent-loop.ts+1−1
@@ -180,7 +180,7 @@ function buildAgentPrompt(userPrompt: string, now: Date): string { · 179 unmodified lines
180180 "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).",
181181 "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).",
182182 "For multiple events, produce multiple tool calls in the same code block.",
183− "Never claim an action succeeded unless run_code returned ok=true and receipts show succeeded calls.",
183+ "Never claim an action succeeded unless run_code returned ok=true.",
184184 `Current timestamp: ${now.toISOString()}`,
185185 `User request: ${userPrompt}`,
186186 ].join("\n");
apps/discord-bot/src/format-response.test.ts+9−82
@@ -1,96 +1,23 @@
1−import type { CodeModeRunResult } from "@openassistant/core";
21 import { describe, expect, it } from "vitest";
32 import { formatDiscordResponse } from "./format-response.js";
43
54 describe("formatDiscordResponse", () => {
6− it("renders a polished verified calendar summary", () => {
5+ it("returns trimmed text and footer", () => {
76 const output = formatDiscordResponse({
8− prompt: "Add 3 calendar events",
9− text: "Added three events.",
10− planner: "Claude tool-loop (3 code runs).",
11− provider: "claude",
12− runs: [
13− { code: "run1", result: runResult("a", "succeeded", "Dinner @ 2026-02-07T17:00:00.000Z") },
14− { code: "run2", result: runResult("b", "succeeded", "Lunch @ 2026-02-08T14:00:00.000Z") },
15− { code: "run3", result: runResult("c", "succeeded", "Breakfast @ 2026-02-09T03:00:00.000Z") },
16− ],
7+ text: " Added dinner to your calendar. ",
8+ footer: "planner details",
179 });
1810
19− expect(output.message).toContain("Added 3 calendar events.");
20− expect(output.message).toContain("- Dinner -");
21− expect(output.message).toContain("- Lunch -");
22− expect(output.message).toContain("- Breakfast -");
23− expect(output.footer).toBeDefined();
24− expect(output.footer).toContain("Activity: 3 tool calls, 3 approved.");
11+ expect(output.message).toBe("Added dinner to your calendar.");
12+ expect(output.footer).toBe("planner details");
2513 });
2614
27− it("shows concise recovery message when some attempts fail but success exists", () => {
15+ it("falls back to Done when text is empty", () => {
2816 const output = formatDiscordResponse({
29− prompt: "Delete event",
30− text: "Could not complete request.",
31− planner: "Claude tool-loop (2 code runs).",
32− provider: "claude",
33− runs: [
34− {
35− code: "bad",
36− result: {
37− ok: false,
38− error: "Typecheck failed: Property 'create' does not exist",
39− receipts: [],
40− },
41− },
42− { code: "good", result: runResult("ok", "succeeded", "Dinner @ 2026-02-07T17:00:00.000Z") },
43− ],
17+ text: " ",
4418 });
4519
46− expect(output.message).toContain("Added 1 calendar event.");
47− expect(output.message).toContain("Recovered from 1 issue during execution.");
48− expect(output.message).not.toContain("code run failed");
49− });
50−
51− it("shows actionable error message when nothing succeeded", () => {
52− const output = formatDiscordResponse({
53− prompt: "Delete event",
54− text: "Could not complete request.",
55− planner: "Claude tool-loop (1 code run).",
56− provider: "claude",
57− runs: [
58− {
59− code: "run",
60− result: {
61− ok: false,
62− error: "Typecheck failed: Property 'create' does not exist",
63− receipts: [],
64− },
65− },
66− ],
67− });
68−
69− expect(output.message).toContain("I couldn't complete that request.");
70− expect(output.message).toContain("I hit an issue:");
71− expect(output.message).toContain("Typecheck failed");
20+ expect(output.message).toBe("Done.");
21+ expect(output.footer).toBeUndefined();
7222 });
7323 });
74−
75−function runResult(
76− callId: string,
77− status: "succeeded" | "failed",
78− inputPreview: string,
79−): CodeModeRunResult {
80− return {
81− ok: true,
82− value: { ok: true },
83− receipts: [
84− {
85− callId,
86− toolPath: "calendar.update",
87− kind: "write",
88− approval: "required",
89− decision: "approved",
90− status,
91− timestamp: "2026-02-06T01:00:00.000Z",
92− inputPreview,
93− },
94− ],
95− };
96−}
apps/discord-bot/src/format-response.ts+4−259
@@ -1,275 +1,20 @@
1−import type { ToolCallReceipt } from "@openassistant/core";
2−import type { AgentCodeRun } from "./agent-loop.js";
3−
41 export interface FormattedDiscordResponse {
52 message: string;
63 footer?: string;
74 }
85
96 export function formatDiscordResponse(params: {
10− prompt: string;
117 text: string;
12− planner: string;
13− provider: "claude";
14− runs: AgentCodeRun[];
8+ footer?: string | undefined;
159 }): FormattedDiscordResponse {
16− const { text, planner, provider, runs } = params;
17− const receipts = runs.flatMap((run) => run.result.receipts);
18− const stats = summarizeReceipts(receipts);
19− const failures = summarizeFailures(runs);
20−
21− const messageSections: string[] = [];
22− messageSections.push(buildPrimaryReply(text, receipts, stats));
23−
24− const issueSummary = buildIssueSummary(stats, failures);
25− if (issueSummary) {
26− messageSections.push(issueSummary);
27− }
28−
29− const footerParts: string[] = [];
30− const toolFooter = buildToolFooter(receipts, stats);
31− if (toolFooter) {
32− footerParts.push(toolFooter);
33− }
34−
35− if (isVerboseMode()) {
36− footerParts.push(`debug: ${provider} - ${planner}`);
37− footerParts.push(`runs: ${runs.length}`);
38− footerParts.push(`tools: ${formatToolStats(stats.byTool)}`);
39− footerParts.push(
40− failures.length > 0
41− ? `failures: ${failures.map((failure) => failure.message).join(" | ")}`
42− : "failures: none",
43− );
44− }
10+ const message = params.text.trim().length > 0 ? params.text.trim() : "Done.";
4511
4612 return {
47− message: truncateDiscord(messageSections.filter((value) => value.trim().length > 0).join("\n\n")),
48− ...(footerParts.length > 0 ? { footer: truncateFooter(footerParts.join(" | ")) } : {}),
13+ message: truncateDiscord(message),
14+ ...(params.footer ? { footer: truncateFooter(params.footer) } : {}),
4915 };
5016 }
5117
52−type ToolStats = {
53− calls: number;
54− approved: number;
55− auto: number;
56− denied: number;
57− succeeded: number;
58− failed: number;
59−};
60−
61−type ReceiptSummary = {
62− totals: ToolStats;
63− byTool: Map<string, ToolStats>;
64−};
65−
66−type FailureEntry = {
67− message: string;
68−};
69−
70−function summarizeReceipts(receipts: ToolCallReceipt[]): ReceiptSummary {
71− const totals: ToolStats = {
72− calls: 0,
73− approved: 0,
74− auto: 0,
75− denied: 0,
76− succeeded: 0,
77− failed: 0,
78− };
79−
80− const byTool = new Map<string, ToolStats>();
81−
82− for (const receipt of receipts) {
83− totals.calls += 1;
84− if (receipt.decision === "approved") {
85− totals.approved += 1;
86− } else if (receipt.decision === "auto") {
87− totals.auto += 1;
88− } else {
89− totals.denied += 1;
90− }
91−
92− if (receipt.status === "succeeded") {
93− totals.succeeded += 1;
94− } else if (receipt.status === "failed") {
95− totals.failed += 1;
96− } else {
97− totals.denied += 1;
98− }
99−
100− const current = byTool.get(receipt.toolPath) ?? {
101− calls: 0,
102− approved: 0,
103− auto: 0,
104− denied: 0,
105− succeeded: 0,
106− failed: 0,
107− };
108−
109− current.calls += 1;
110− if (receipt.decision === "approved") {
111− current.approved += 1;
112− } else if (receipt.decision === "auto") {
113− current.auto += 1;
114− } else {
115− current.denied += 1;
116− }
117−
118− if (receipt.status === "succeeded") {
119− current.succeeded += 1;
120− } else if (receipt.status === "failed") {
121− current.failed += 1;
122− } else {
123− current.denied += 1;
124− }
125−
126− byTool.set(receipt.toolPath, current);
127− }
128−
129− return { totals, byTool };
130−}
131−
132−function summarizeFailures(runs: AgentCodeRun[]): FailureEntry[] {
133− const failures: FailureEntry[] = [];
134− for (const run of runs) {
135− if (!run.result.ok) {
136− failures.push({ message: run.result.error });
137− }
138−
139− for (const receipt of run.result.receipts) {
140− if (receipt.status === "failed") {
141− failures.push({
142− message: `${receipt.toolPath} failed${receipt.error ? `: ${receipt.error}` : ""}`,
143− });
144− }
145− if (receipt.status === "denied") {
146− failures.push({
147− message: `${receipt.toolPath} was denied`,
148− });
149− }
150− }
151− }
152− return failures;
153−}
154−
155−function buildPrimaryReply(text: string, receipts: ToolCallReceipt[], stats: ReceiptSummary): string {
156− const calendarUpdates = extractCalendarUpdates(receipts);
157− if (calendarUpdates.length > 0) {
158− const header =
159− calendarUpdates.length === 1
160− ? "Added 1 calendar event."
161− : `Added ${calendarUpdates.length} calendar events.`;
162− const lines = [header, ...calendarUpdates.map((entry) => `- ${entry.title} - ${formatIsoUtc(entry.startsAt)}`)];
163− return lines.join("\n");
164− }
165−
166− if (stats.totals.succeeded > 0) {
167− return text.trim() || "Completed.";
168− }
169−
170− return "I couldn't complete that request.";
171−}
172−
173−function buildToolFooter(receipts: ToolCallReceipt[], stats: ReceiptSummary): string | null {
174− if (receipts.length === 0) {
175− return null;
176− }
177−
178− const parts: string[] = [];
179− parts.push(`${stats.totals.calls} tool call${stats.totals.calls === 1 ? "" : "s"}`);
180− if (stats.totals.approved > 0) {
181− parts.push(`${stats.totals.approved} approved`);
182− }
183− if (stats.totals.auto > 0) {
184− parts.push(`${stats.totals.auto} auto`);
185− }
186− if (stats.totals.failed > 0) {
187− parts.push(`${stats.totals.failed} failed`);
188− }
189− if (stats.totals.denied > 0) {
190− parts.push(`${stats.totals.denied} denied`);
191− }
192−
193− return `Activity: ${parts.join(", ")}.`;
194−}
195−
196−function buildIssueSummary(stats: ReceiptSummary, failures: FailureEntry[]): string | null {
197− if (failures.length === 0) {
198− return null;
199− }
200−
201− if (stats.totals.succeeded > 0) {
202− return `Recovered from ${failures.length} issue${failures.length === 1 ? "" : "s"} during execution.`;
203− }
204−
205− const top = failures.slice(0, 2).map((failure) => `- ${failure.message}`);
206− return ["I hit an issue:", ...top].join("\n");
207−}
208−
209−function extractCalendarUpdates(receipts: ToolCallReceipt[]): Array<{ title: string; startsAt: string }> {
210− const updates: Array<{ title: string; startsAt: string }> = [];
211−
212− for (const receipt of receipts) {
213− if (receipt.toolPath !== "calendar.update" || receipt.status !== "succeeded") {
214− continue;
215− }
216− const preview = receipt.inputPreview;
217− if (!preview) {
218− continue;
219− }
220− const split = preview.lastIndexOf(" @ ");
221− if (split <= 0 || split >= preview.length - 3) {
222− continue;
223− }
224−
225− const title = preview.slice(0, split).trim();
226− const startsAt = preview.slice(split + 3).trim();
227− if (!title || !startsAt) {
228− continue;
229− }
230−
231− updates.push({ title, startsAt });
232− }
233−
234− return updates;
235−}
236−
237−function formatIsoUtc(value: string): string {
238− const date = new Date(value);
239− if (Number.isNaN(date.getTime())) {
240− return value;
241− }
242− return new Intl.DateTimeFormat("en-US", {
243− timeZone: "UTC",
244− weekday: "short",
245− month: "short",
246− day: "numeric",
247− year: "numeric",
248− hour: "numeric",
249− minute: "2-digit",
250− hour12: true,
251− }).format(date) + " UTC";
252−}
253−
254−function formatToolStats(byTool: Map<string, ToolStats>): string {
255− if (byTool.size === 0) {
256− return "none";
257− }
258− return Array.from(byTool.entries())
259− .map(([tool, stat]) => `${tool}: ${stat.calls} calls, ${stat.succeeded} succeeded${stat.failed ? `, ${stat.failed} failed` : ""}${stat.denied ? `, ${stat.denied} denied` : ""}`)
260− .join("; ");
261−}
262−
263−function isVerboseMode(): boolean {
264− const value = readEnv("OPENASSISTANT_VERBOSE_RESPONSE")?.trim().toLowerCase();
265− return value === "1" || value === "true" || value === "yes";
266−}
267−
268−function readEnv(key: string): string | undefined {
269− const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
270− return bun?.env?.[key] ?? process.env[key];
271−}
272−
27318 function truncateDiscord(value: string): string {
27419 const limit = 1_900;
27520 if (value.length <= limit) {
apps/discord-bot/src/gateway-client.ts+35−0
@@ -0,0 +1,35 @@
1+import { FetchHttpClient } from "@effect/platform";
2+import { RpcClient, RpcSerialization } from "@effect/rpc";
3+import { AgentRpcs, type RunTurnOutput } from "@openassistant/core";
4+import { Effect, Layer } from "effect";
5+
6+const gatewayUrl = readEnv("OPENASSISTANT_GATEWAY_URL") ?? "http://127.0.0.1:8787/rpc";
7+
8+const GatewayRpcClientLive = RpcClient.layerProtocolHttp({
9+ url: gatewayUrl,
10+}).pipe(Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson]));
11+
12+export interface RunGatewayTurnInput {
13+ prompt: string;
14+ requesterId: string;
15+ channelId: string;
16+}
17+
18+export async function runGatewayTurn(input: RunGatewayTurnInput): Promise<RunTurnOutput> {
19+ return Effect.runPromise(
20+ Effect.gen(function* () {
21+ const client = yield* RpcClient.make(AgentRpcs);
22+ return yield* client.RunTurn({
23+ prompt: input.prompt,
24+ requesterId: input.requesterId,
25+ channelId: input.channelId,
26+ nowIso: new Date().toISOString(),
27+ });
28+ }).pipe(Effect.scoped, Effect.provide(GatewayRpcClientLive)),
29+ );
30+}
31+
32+function readEnv(key: string): string | undefined {
33+ const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
34+ return bun?.env?.[key] ?? process.env[key];
35+}
apps/discord-bot/src/index.tsx+7−30
@@ -1,4 +1,3 @@
1−import { createCodeModeRunner } from "@openassistant/core";
21 import { makeReacord } from "@openassistant/reacord";
32 import {
43 ChannelType,
@@ -10,20 +9,15 @@ import { · 5 unmodified lines
109 type SendableChannels,
1110 } from "discord.js";
1211 import { Effect } from "effect";
13−import { DiscordApprovalBridge } from "./approval-bridge.js";
14−import { runAgentLoop } from "./agent-loop.js";
15−import { InMemoryCalendarStore } from "./calendar-store.js";
1612 import { AssistantReplyView, AssistantWorkingView } from "./discord-views.js";
1713 import { formatDiscordResponse } from "./format-response.js";
18−import { createToolTree } from "./tools.js";
14+import { runGatewayTurn } from "./gateway-client.js";
1915
2016 const token = Bun.env.DISCORD_BOT_TOKEN;
2117 if (!token) {
2218 throw new Error("Missing DISCORD_BOT_TOKEN");
2319 }
2420
25−const approvalTimeoutMs = Number(Bun.env.OPENASSISTANT_APPROVAL_TIMEOUT_MS ?? 300_000);
26−
2721 const client = new Client({
2822 intents: [
2923 GatewayIntentBits.Guilds,
@@ -34,10 +28,7 @@ const client = new Client({ · 4 unmodified lines
3428 partials: [Partials.Channel],
3529 });
3630
37−const approvalBridge = new DiscordApprovalBridge(client, approvalTimeoutMs);
3831 const reacord = makeReacord(client);
39−const calendarStore = new InMemoryCalendarStore();
40−const tools = createToolTree(calendarStore);
4132
4233 client.once(Events.ClientReady, (readyClient) => {
4334 console.log(`[discord-bot] logged in as ${readyClient.user.tag}`);
@@ -70,29 +61,15 @@ async function handleMessage(message: Message): Promise<void> { · 26 unmodified lines
7061 );
7162
7263 try {
73− const runner = createCodeModeRunner({
74− tools,
75− requestApproval: (request) =>
76− Effect.tryPromise({
77− try: () =>
78− approvalBridge.requestApproval({
79− request,
80− channel: approvalChannel,
81− requesterId: message.author.id,
82− }),
83− catch: (error) => error,
84− }),
64+ const generated = await runGatewayTurn({
65+ prompt: message.content,
66+ requesterId: message.author.id,
67+ channelId: message.channelId,
8568 });
8669
87− const generated = await runAgentLoop(message.content, (code) =>
88− Effect.runPromise(runner.run({ code })),
89− );
9070 const response = formatDiscordResponse({
91− prompt: message.content,
92− text: generated.text,
93− planner: generated.planner,
94− provider: generated.provider,
95− runs: generated.runs,
71+ text: generated.message,
72+ footer: generated.footer,
9673 });
9774
9875 instance.render(<AssistantReplyView message={response.message} footer={response.footer} />);
apps/gateway/package.json+7−1
@@ -15,7 +15,13 @@ · 14 unmodified lines
1515 "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\""
1616 },
1717 "dependencies": {
18+ "@ai-sdk/anthropic": "^2.0.0",
19+ "@effect/platform": "latest",
20+ "@effect/platform-bun": "latest",
21+ "@effect/rpc": "latest",
1822 "@openassistant/core": "workspace:*",
19− "effect": "latest"
23+ "ai": "^6.0.73",
24+ "effect": "latest",
25+ "zod": "^4.3.6"
2026 }
2127 }
apps/gateway/src/agent-loop.ts+199−0
@@ -0,0 +1,199 @@
1+import type { CodeModeRunResult, ToolCallReceipt } from "@openassistant/core";
2+import { generateText, stepCountIs, tool } from "ai";
3+import { getAnthropicModel } from "./anthropic-provider.js";
4+import { typecheckCodeSnippet } from "./code-typecheck.js";
5+import { z } from "zod";
6+
7+export interface AgentCodeRun {
8+ code: string;
9+ result: CodeModeRunResult;
10+ reason?: string | undefined;
11+}
12+
13+export interface AgentLoopResult {
14+ provider: "claude";
15+ planner: string;
16+ text: string;
17+ runs: AgentCodeRun[];
18+}
19+
20+interface RunCodeToolInput {
21+ code: string;
22+ reason?: string | undefined;
23+}
24+
25+interface RunCodeToolOutput {
26+ ok: boolean;
27+ receipts: ToolCallReceipt[];
28+ value?: unknown;
29+ error?: string;
30+}
31+
32+interface RunWithClaudeInput {
33+ prompt: string;
34+ now: Date;
35+ executeCode: (input: RunCodeToolInput) => Promise<RunCodeToolOutput>;
36+}
37+
38+interface RunWithClaudeOutput {
39+ text: string;
40+ modelID: string;
41+ authSource: string;
42+}
43+
44+interface AgentLoopOptions {
45+ now?: Date;
46+ runWithClaude?: (input: RunWithClaudeInput) => Promise<RunWithClaudeOutput>;
47+}
48+
49+const DEFAULT_ANTHROPIC_MODEL =
50+ readEnv("OPENASSISTANT_ANTHROPIC_MODEL")?.trim() ??
51+ readEnv("OPENASSISTANT_CLAUDE_MODEL")?.trim() ??
52+ "claude-opus-4-5-20251101";
53+const CLAUDE_TIMEOUT_MS = Number(readEnv("OPENASSISTANT_CLAUDE_TIMEOUT_MS") ?? 60_000);
54+const CLAUDE_MAX_STEPS = Number(readEnv("OPENASSISTANT_AGENT_MAX_STEPS") ?? 8);
55+
56+const RUN_CODE_TOOL_SCHEMA = z.object({
57+ code: z.string().min(1),
58+ reason: z.string().min(1).optional(),
59+});
60+
61+export async function runAgentLoop(
62+ prompt: string,
63+ runCode: (code: string) => Promise<CodeModeRunResult>,
64+ options: AgentLoopOptions = {},
65+): Promise<AgentLoopResult> {
66+ const now = options.now ?? new Date();
67+ const runs: AgentCodeRun[] = [];
68+
69+ const executeCode = async (input: RunCodeToolInput): Promise<RunCodeToolOutput> => {
70+ const typecheck = typecheckCodeSnippet(input.code);
71+ if (!typecheck.ok) {
72+ const failed: CodeModeRunResult = {
73+ ok: false,
74+ error: `Typecheck failed: ${typecheck.error}`,
75+ receipts: [],
76+ };
77+ runs.push({
78+ code: input.code,
79+ result: failed,
80+ ...(input.reason ? { reason: input.reason } : {}),
81+ });
82+ return {
83+ ok: false,
84+ error: failed.error,
85+ receipts: [],
86+ };
87+ }
88+
89+ const result = await runCode(input.code);
90+ runs.push({
91+ code: input.code,
92+ result,
93+ ...(input.reason ? { reason: input.reason } : {}),
94+ });
95+
96+ if (result.ok) {
97+ return {
98+ ok: true,
99+ value: result.value,
100+ receipts: result.receipts,
101+ };
102+ }
103+
104+ return {
105+ ok: false,
106+ error: result.error,
107+ receipts: result.receipts,
108+ };
109+ };
110+
111+ try {
112+ const generated = await (options.runWithClaude ?? runWithClaude)({
113+ prompt,
114+ now,
115+ executeCode,
116+ });
117+
118+ return {
119+ provider: "claude",
120+ planner: `Claude tool-loop (${runs.length} code run${runs.length === 1 ? "" : "s"}, model=${generated.modelID}, auth=${generated.authSource}).`,
121+ text: generated.text,
122+ runs,
123+ };
124+ } catch (error) {
125+ return {
126+ provider: "claude",
127+ planner: `Claude tool-loop failed before completion (model=${DEFAULT_ANTHROPIC_MODEL}).`,
128+ text: `Agent loop failed: ${describeUnknown(error)}. No actions were executed.`,
129+ runs,
130+ };
131+ }
132+}
133+
134+async function runWithClaude(input: RunWithClaudeInput): Promise<RunWithClaudeOutput> {
135+ const abortController = new AbortController();
136+ const timeout = setTimeout(() => abortController.abort(), CLAUDE_TIMEOUT_MS);
137+
138+ try {
139+ const { model, authSource, authMode } = await getAnthropicModel(DEFAULT_ANTHROPIC_MODEL);
140+ const result = await generateText({
141+ model,
142+ temperature: 0,
143+ stopWhen: stepCountIs(CLAUDE_MAX_STEPS),
144+ prompt: buildAgentPrompt(input.prompt, input.now),
145+ system: buildSystemPrompt(authMode),
146+ tools: {
147+ run_code: tool({
148+ description:
149+ "Execute Bun TypeScript function body in codemode runtime. Use this for every action that requires tools.* calls.",
150+ inputSchema: RUN_CODE_TOOL_SCHEMA,
151+ execute: (args) => input.executeCode(args),
152+ }),
153+ },
154+ abortSignal: abortController.signal,
155+ });
156+
157+ return {
158+ text: result.text.trim() || "Done.",
159+ modelID: DEFAULT_ANTHROPIC_MODEL,
160+ authSource,
161+ };
162+ } finally {
163+ clearTimeout(timeout);
164+ }
165+}
166+
167+function buildSystemPrompt(authMode: "api" | "oauth"): string {
168+ if (authMode === "oauth") {
169+ return "You are Claude Code, Anthropic's official CLI for Claude.";
170+ }
171+
172+ return [
173+ "You are OpenAssistant. Execute work via the run_code tool and then report what happened.",
174+ "In your final response, summarize relevant tool activity and mention failures/denials clearly.",
175+ ].join("\n");
176+}
177+
178+function buildAgentPrompt(userPrompt: string, now: Date): string {
179+ return [
180+ "run_code expects JavaScript function-body code executed as new AsyncFunction('tools', code).",
181+ "Inside code, call available tools directly like: await tools.calendar.update({ title, startsAt, notes }).",
182+ "For multiple events, produce multiple tool calls in the same code block.",
183+ "Never claim an action succeeded unless run_code returned ok=true.",
184+ `Current timestamp: ${now.toISOString()}`,
185+ `User request: ${userPrompt}`,
186+ ].join("\n");
187+}
188+
189+function describeUnknown(error: unknown): string {
190+ if (error instanceof Error) {
191+ return error.message;
192+ }
193+ return String(error);
194+}
195+
196+function readEnv(key: string): string | undefined {
197+ const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
198+ return bun?.env?.[key] ?? process.env[key];
199+}
apps/gateway/src/anthropic-auth.ts+133−0
@@ -0,0 +1,133 @@
1+import { homedir } from "node:os";
2+import { join } from "node:path";
3+
4+const DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
5+
6+export type ResolvedAnthropicAuth =
7+ | {
8+ mode: "api";
9+ apiKey: string;
10+ source: string;
11+ }
12+ | {
13+ mode: "oauth";
14+ accessToken: string;
15+ refreshToken: string | undefined;
16+ expiresAt: number | undefined;
17+ clientId: string;
18+ source: string;
19+ };
20+
21+export async function resolveAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> {
22+ const explicitApiKey = env("OPENASSISTANT_ANTHROPIC_API_KEY") ?? env("ANTHROPIC_API_KEY");
23+ if (explicitApiKey) {
24+ return {
25+ mode: "api",
26+ apiKey: explicitApiKey,
27+ source: env("OPENASSISTANT_ANTHROPIC_API_KEY") ? "env:OPENASSISTANT_ANTHROPIC_API_KEY" : "env:ANTHROPIC_API_KEY",
28+ };
29+ }
30+
31+ const explicitAccess = env("OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN");
32+ if (explicitAccess) {
33+ const refreshToken = env("OPENASSISTANT_ANTHROPIC_REFRESH_TOKEN");
34+ const expiresAt = parseMaybeNumber(env("OPENASSISTANT_ANTHROPIC_EXPIRES_AT"));
35+ return {
36+ mode: "oauth",
37+ accessToken: explicitAccess,
38+ refreshToken,
39+ expiresAt,
40+ clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID,
41+ source: "env:OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN",
42+ };
43+ }
44+
45+ const opencodeAuth = await loadOpencodeAnthropicAuth();
46+ if (opencodeAuth) {
47+ return opencodeAuth;
48+ }
49+
50+ return null;
51+}
52+
53+async function loadOpencodeAnthropicAuth(): Promise<ResolvedAnthropicAuth | null> {
54+ const filepath = join(opencodeDataDir(), "auth.json");
55+ const file = Bun.file(filepath);
56+ const exists = await file.exists();
57+ if (!exists) {
58+ return null;
59+ }
60+
61+ const raw = (await file.json().catch(() => null)) as Record<string, unknown> | null;
62+ if (!raw || typeof raw !== "object") {
63+ return null;
64+ }
65+
66+ const anthropic = raw["anthropic"];
67+ if (!anthropic || typeof anthropic !== "object") {
68+ return null;
69+ }
70+
71+ const record = anthropic as Record<string, unknown>;
72+ const type = asString(record["type"]);
73+ if (type === "api") {
74+ const key = asString(record["key"]);
75+ if (!key) {
76+ return null;
77+ }
78+ return {
79+ mode: "api",
80+ apiKey: key,
81+ source: "opencode:auth.json(api)",
82+ };
83+ }
84+
85+ if (type === "oauth") {
86+ const accessToken = asString(record["access"]);
87+ if (!accessToken) {
88+ return null;
89+ }
90+ const refreshToken = asString(record["refresh"]);
91+ const expiresAt = asNumber(record["expires"]);
92+
93+ return {
94+ mode: "oauth",
95+ accessToken,
96+ refreshToken,
97+ expiresAt,
98+ clientId: env("OPENASSISTANT_ANTHROPIC_CLIENT_ID") ?? DEFAULT_ANTHROPIC_OAUTH_CLIENT_ID,
99+ source: "opencode:auth.json(oauth)",
100+ };
101+ }
102+
103+ return null;
104+}
105+
106+function opencodeDataDir(): string {
107+ const xdg = env("XDG_DATA_HOME");
108+ if (xdg) {
109+ return join(xdg, "opencode");
110+ }
111+ return join(homedir(), ".local", "share", "opencode");
112+}
113+
114+function asString(value: unknown): string | undefined {
115+ return typeof value === "string" && value.length > 0 ? value : undefined;
116+}
117+
118+function asNumber(value: unknown): number | undefined {
119+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
120+}
121+
122+function parseMaybeNumber(value: string | undefined): number | undefined {
123+ if (!value) {
124+ return undefined;
125+ }
126+ const parsed = Number(value);
127+ return Number.isFinite(parsed) ? parsed : undefined;
128+}
129+
130+function env(key: string): string | undefined {
131+ const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
132+ return bun?.env?.[key] ?? process.env[key];
133+}
apps/gateway/src/anthropic-provider.ts+284−0
@@ -0,0 +1,284 @@
1+import { createAnthropic } from "@ai-sdk/anthropic";
2+import type { LanguageModel } from "ai";
3+import { resolveAnthropicAuth, type ResolvedAnthropicAuth } from "./anthropic-auth.js";
4+
5+const OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
6+const REQUIRED_OAUTH_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
7+const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)";
8+const TOOL_PREFIX = "mcp_";
9+
10+let cachedAuthPromise: Promise<ResolvedAnthropicAuth | null> | undefined;
11+let refreshInFlight: Promise<void> | undefined;
12+
13+export interface AnthropicModelSelection {
14+ model: LanguageModel;
15+ authSource: string;
16+ authMode: "api" | "oauth";
17+}
18+
19+export async function getAnthropicModel(modelID: string): Promise<AnthropicModelSelection> {
20+ const auth = await getResolvedAuth();
21+ if (!auth) {
22+ throw new Error(
23+ "No Anthropic credentials found. Set OPENASSISTANT_ANTHROPIC_API_KEY or OPENASSISTANT_ANTHROPIC_ACCESS_TOKEN (or configure opencode auth).",
24+ );
25+ }
26+
27+ if (auth.mode === "api") {
28+ const anthropic = createAnthropic({
29+ apiKey: auth.apiKey,
30+ });
31+ return {
32+ model: anthropic(modelID),
33+ authSource: auth.source,
34+ authMode: "api",
35+ };
36+ }
37+
38+ const oauthFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
39+ await ensureFreshToken(auth);
40+
41+ const headers = mergeHeaders(input, init?.headers);
42+ headers.set("authorization", `Bearer ${auth.accessToken}`);
43+ headers.set("anthropic-beta", mergeBetas(headers.get("anthropic-beta")));
44+ headers.set("user-agent", CLAUDE_CLI_USER_AGENT);
45+ headers.delete("x-api-key");
46+
47+ const transformedBody = transformRequestBody(init?.body);
48+ const withBetaURL = ensureBetaMessagesURL(input);
49+
50+ const response = await fetch(withBetaURL, {
51+ ...init,
52+ headers,
53+ ...(transformedBody !== undefined ? { body: transformedBody } : {}),
54+ });
55+
56+ return transformResponseBody(response);
57+ }) as typeof fetch;
58+
59+ const anthropic = createAnthropic({
60+ apiKey: "opencode-oauth-dummy-key",
61+ fetch: oauthFetch,
62+ });
63+
64+ return {
65+ model: anthropic(modelID),
66+ authSource: auth.source,
67+ authMode: "oauth",
68+ };
69+}
70+
71+async function getResolvedAuth(): Promise<ResolvedAnthropicAuth | null> {
72+ if (!cachedAuthPromise) {
73+ cachedAuthPromise = resolveAnthropicAuth();
74+ }
75+ return cachedAuthPromise;
76+}
77+
78+async function ensureFreshToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> {
79+ const now = Date.now();
80+ const expiresAt = auth.expiresAt;
81+ const mustRefresh = !auth.accessToken || (typeof expiresAt === "number" && now + 30_000 >= expiresAt);
82+ if (!mustRefresh) {
83+ return;
84+ }
85+
86+ if (!auth.refreshToken) {
87+ throw new Error("Anthropic OAuth access token expired and no refresh token is available.");
88+ }
89+
90+ if (!refreshInFlight) {
91+ refreshInFlight = refreshAccessToken(auth).finally(() => {
92+ refreshInFlight = undefined;
93+ });
94+ }
95+
96+ await refreshInFlight;
97+}
98+
99+async function refreshAccessToken(auth: Extract<ResolvedAnthropicAuth, { mode: "oauth" }>): Promise<void> {
100+ const response = await fetch(OAUTH_TOKEN_URL, {
101+ method: "POST",
102+ headers: {
103+ "Content-Type": "application/json",
104+ },
105+ body: JSON.stringify({
106+ grant_type: "refresh_token",
107+ refresh_token: auth.refreshToken,
108+ client_id: auth.clientId,
109+ }),
110+ });
111+
112+ if (!response.ok) {
113+ throw new Error(`Anthropic OAuth token refresh failed (${response.status}).`);
114+ }
115+
116+ const json = (await response.json()) as {
117+ access_token?: unknown;
118+ refresh_token?: unknown;
119+ expires_in?: unknown;
120+ };
121+
122+ if (typeof json.access_token !== "string" || json.access_token.length === 0) {
123+ throw new Error("Anthropic OAuth token refresh returned no access token.");
124+ }
125+
126+ auth.accessToken = json.access_token;
127+ if (typeof json.refresh_token === "string" && json.refresh_token.length > 0) {
128+ auth.refreshToken = json.refresh_token;
129+ }
130+ if (typeof json.expires_in === "number" && Number.isFinite(json.expires_in)) {
131+ auth.expiresAt = Date.now() + json.expires_in * 1_000;
132+ }
133+}
134+
135+function mergeBetas(existing: string | null): string {
136+ const current = (existing ?? "")
137+ .split(",")
138+ .map((item) => item.trim())
139+ .filter((item) => item.length > 0);
140+
141+ return [...new Set([...REQUIRED_OAUTH_BETAS, ...current])].join(",");
142+}
143+
144+function mergeHeaders(input: RequestInfo | URL, initHeaders: HeadersInit | undefined): Headers {
145+ const headers = new Headers();
146+
147+ if (input instanceof Request) {
148+ input.headers.forEach((value, key) => {
149+ headers.set(key, value);
150+ });
151+ }
152+
153+ if (initHeaders) {
154+ const candidate = new Headers(initHeaders);
155+ candidate.forEach((value, key) => {
156+ headers.set(key, value);
157+ });
158+ }
159+
160+ return headers;
161+}
162+
163+function transformRequestBody(body: RequestInit["body"]): RequestInit["body"] {
164+ if (typeof body !== "string") {
165+ return body;
166+ }
167+
168+ try {
169+ const parsed = JSON.parse(body) as Record<string, unknown>;
170+ const tools = parsed["tools"];
171+ if (Array.isArray(tools)) {
172+ parsed["tools"] = tools.map((tool) => {
173+ if (!tool || typeof tool !== "object") {
174+ return tool;
175+ }
176+ const candidate = tool as Record<string, unknown>;
177+ const name = candidate["name"];
178+ if (typeof name === "string" && name.length > 0 && !name.startsWith(TOOL_PREFIX)) {
179+ return {
180+ ...candidate,
181+ name: `${TOOL_PREFIX}${name}`,
182+ };
183+ }
184+ return tool;
185+ });
186+ }
187+
188+ const messages = parsed["messages"];
189+ if (Array.isArray(messages)) {
190+ parsed["messages"] = messages.map((message) => {
191+ if (!message || typeof message !== "object") {
192+ return message;
193+ }
194+ const candidate = message as Record<string, unknown>;
195+ const content = candidate["content"];
196+ if (!Array.isArray(content)) {
197+ return message;
198+ }
199+ return {
200+ ...candidate,
201+ content: content.map((block) => {
202+ if (!block || typeof block !== "object") {
203+ return block;
204+ }
205+ const value = block as Record<string, unknown>;
206+ if (value["type"] !== "tool_use") {
207+ return block;
208+ }
209+ const name = value["name"];
210+ if (typeof name !== "string" || name.length === 0 || name.startsWith(TOOL_PREFIX)) {
211+ return block;
212+ }
213+ return {
214+ ...value,
215+ name: `${TOOL_PREFIX}${name}`,
216+ };
217+ }),
218+ };
219+ });
220+ }
221+
222+ return JSON.stringify(parsed);
223+ } catch {
224+ return body;
225+ }
226+}
227+
228+function ensureBetaMessagesURL(input: RequestInfo | URL): RequestInfo | URL {
229+ let inputIsRequest = false;
230+ let parsed: URL | null = null;
231+ try {
232+ if (input instanceof Request) {
233+ inputIsRequest = true;
234+ parsed = new URL(input.url);
235+ } else if (input instanceof URL) {
236+ parsed = new URL(input.toString());
237+ } else if (typeof input === "string") {
238+ parsed = new URL(input);
239+ }
240+ } catch {
241+ parsed = null;
242+ }
243+
244+ if (!parsed || parsed.pathname !== "/v1/messages" || parsed.searchParams.has("beta")) {
245+ return input;
246+ }
247+
248+ parsed.searchParams.set("beta", "true");
249+ if (inputIsRequest && input instanceof Request) {
250+ return new Request(parsed.toString(), input);
251+ }
252+ return parsed;
253+}
254+
255+async function transformResponseBody(response: Response): Promise<Response> {
256+ if (!response.body) {
257+ return response;
258+ }
259+
260+ const reader = response.body.getReader();
261+ const decoder = new TextDecoder();
262+ const encoder = new TextEncoder();
263+
264+ const stream = new ReadableStream({
265+ async pull(controller) {
266+ const { done, value } = await reader.read();
267+ if (done) {
268+ controller.close();
269+ return;
270+ }
271+
272+ const text = decoder
273+ .decode(value, { stream: true })
274+ .replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
275+ controller.enqueue(encoder.encode(text));
276+ },
277+ });
278+
279+ return new Response(stream, {
280+ status: response.status,
281+ statusText: response.statusText,
282+ headers: response.headers,
283+ });
284+}
apps/gateway/src/calendar-store.ts+32−0
@@ -0,0 +1,32 @@
1+export interface CalendarEvent {
2+ id: string;
3+ title: string;
4+ startsAt: string;
5+ notes?: string;
6+}
7+
8+export interface CalendarUpdateInput {
9+ title: string;
10+ startsAt: string;
11+ notes?: string;
12+}
13+
14+export class InMemoryCalendarStore {
15+ private readonly events = new Map<string, CalendarEvent>();
16+
17+ update(input: CalendarUpdateInput): CalendarEvent {
18+ const id = `evt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
19+ const event: CalendarEvent = {
20+ id,
21+ title: input.title,
22+ startsAt: input.startsAt,
23+ ...(input.notes ? { notes: input.notes } : {}),
24+ };
25+ this.events.set(id, event);
26+ return event;
27+ }
28+
29+ list(): CalendarEvent[] {
30+ return [...this.events.values()];
31+ }
32+}
apps/gateway/src/code-typecheck.ts+87−0
@@ -0,0 +1,87 @@
1+import ts from "typescript";
2+
3+const VIRTUAL_FILE = "/__openassistant__/codemode-check.ts";
4+
5+const TOOL_DECLARATIONS = [
6+ "type CalendarUpdateInput = { title: string; startsAt: string; notes?: string };",
7+ "declare const tools: {",
8+ " calendar: {",
9+ " update(input: CalendarUpdateInput): Promise<unknown>;",
10+ " list(): Promise<unknown>;",
11+ " };",
12+ "};",
13+].join("\n");
14+
15+export type CodeTypecheckResult =
16+ | { ok: true }
17+ | { ok: false; error: string };
18+
19+export function typecheckCodeSnippet(code: string): CodeTypecheckResult {
20+ const source = [
21+ TOOL_DECLARATIONS,
22+ "async function __openassistant_check() {",
23+ code,
24+ "}",
25+ ].join("\n\n");
26+
27+ const compilerOptions: ts.CompilerOptions = {
28+ target: ts.ScriptTarget.ES2022,
29+ module: ts.ModuleKind.ESNext,
30+ moduleResolution: ts.ModuleResolutionKind.Bundler,
31+ strict: true,
32+ noEmit: true,
33+ skipLibCheck: true,
34+ };
35+
36+ const host = ts.createCompilerHost(compilerOptions, true);
37+ const originalGetSourceFile = host.getSourceFile.bind(host);
38+ const originalReadFile = host.readFile.bind(host);
39+ const originalFileExists = host.fileExists.bind(host);
40+
41+ host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
42+ if (fileName === VIRTUAL_FILE) {
43+ return ts.createSourceFile(fileName, source, languageVersion, true);
44+ }
45+ return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
46+ };
47+
48+ host.readFile = (fileName) => {
49+ if (fileName === VIRTUAL_FILE) {
50+ return source;
51+ }
52+ return originalReadFile(fileName);
53+ };
54+
55+ host.fileExists = (fileName) => {
56+ if (fileName === VIRTUAL_FILE) {
57+ return true;
58+ }
59+ return originalFileExists(fileName);
60+ };
61+
62+ const program = ts.createProgram([VIRTUAL_FILE], compilerOptions, host);
63+ const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => {
64+ if (!diagnostic.file) {
65+ return true;
66+ }
67+ return diagnostic.file.fileName === VIRTUAL_FILE;
68+ });
69+
70+ if (diagnostics.length === 0) {
71+ return { ok: true };
72+ }
73+
74+ const message = diagnostics
75+ .slice(0, 3)
76+ .map((diagnostic) => {
77+ const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
78+ if (!diagnostic.file || diagnostic.start === undefined) {
79+ return text;
80+ }
81+ const lineAndCharacter = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
82+ return `${lineAndCharacter.line + 1}:${lineAndCharacter.character + 1} ${text}`;
83+ })
84+ .join(" | ");
85+
86+ return { ok: false, error: message };
87+}
apps/gateway/src/index.ts+70−6
@@ -1,10 +1,74 @@
1−import { healthcheck } from "@openassistant/core";
2−import { Effect } from "effect";
1+import { HttpRouter } from "@effect/platform";
2+import { BunHttpServer, BunRuntime } from "@effect/platform-bun";
3+import { RpcSerialization, RpcServer } from "@effect/rpc";
4+import { AgentRpcs, RunTurnOutput, createCodeModeRunner } from "@openassistant/core";
5+import { Effect, Layer } from "effect";
6+import { runAgentLoop } from "./agent-loop.js";
7+import { InMemoryCalendarStore } from "./calendar-store.js";
8+import { createToolTree } from "./tools.js";
39
4−const program = Effect.flatMap(healthcheck, (status) =>
5− Effect.sync(() => {
6− console.log(`[gateway] healthcheck=${status}`);
10+const port = Number(readEnv("OPENASSISTANT_GATEWAY_PORT") ?? "8787");
11+
12+const calendarStore = new InMemoryCalendarStore();
13+const tools = createToolTree(calendarStore);
14+const runner = createCodeModeRunner({
15+ tools,
16+ requestApproval: () => Effect.succeed("approved"),
17+});
18+
19+const AgentHandlersLive = AgentRpcs.toLayer(
20+ Effect.succeed({
21+ RunTurn: (input: { prompt: string; nowIso: string }) =>
22+ Effect.tryPromise({
23+ try: async () => {
24+ const generated = await runAgentLoop(
25+ input.prompt,
26+ (code) => Effect.runPromise(runner.run({ code })),
27+ {
28+ now: new Date(input.nowIso),
29+ },
30+ );
31+
32+ return new RunTurnOutput({
33+ message: generated.text,
34+ planner: generated.planner,
35+ codeRuns: generated.runs.length,
36+ ...(isVerboseMode() ? { footer: generated.planner } : {}),
37+ });
38+ },
39+ catch: (error) => `RunTurn failed: ${describeUnknown(error)}`,
40+ }),
741 }),
842 );
943
10−await Effect.runPromise(program);
44+const RpcLayer = RpcServer.layer(AgentRpcs).pipe(Layer.provide(AgentHandlersLive));
45+
46+const HttpProtocolLayer = RpcServer.layerProtocolHttp({
47+ path: "/rpc",
48+}).pipe(Layer.provide(RpcSerialization.layerNdjson));
49+
50+const MainLayer = HttpRouter.Default.serve().pipe(
51+ Layer.provide(RpcLayer),
52+ Layer.provide(HttpProtocolLayer),
53+ Layer.provide(BunHttpServer.layer({ port })),
54+);
55+
56+console.log(`[gateway] listening on http://localhost:${port}/rpc`);
57+BunRuntime.runMain(Layer.launch(MainLayer));
58+
59+function describeUnknown(error: unknown): string {
60+ if (error instanceof Error) {
61+ return error.message;
62+ }
63+ return String(error);
64+}
65+
66+function isVerboseMode(): boolean {
67+ const value = readEnv("OPENASSISTANT_VERBOSE_RESPONSE")?.trim().toLowerCase();
68+ return value === "1" || value === "true" || value === "yes";
69+}
70+
71+function readEnv(key: string): string | undefined {
72+ const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
73+ return bun?.env?.[key] ?? process.env[key];
74+}
apps/gateway/src/tools.ts+22−0
@@ -0,0 +1,22 @@
1+import { defineTool, type ToolTree } from "@openassistant/core";
2+import { Effect } from "effect";
3+import type { InMemoryCalendarStore } from "./calendar-store.js";
4+
5+export function createToolTree(calendarStore: InMemoryCalendarStore): ToolTree {
6+ return {
7+ calendar: {
8+ update: defineTool({
9+ kind: "write",
10+ approval: "required",
11+ run: (input: { title: string; startsAt: string; notes?: string }) =>
12+ Effect.sync(() => calendarStore.update(input)),
13+ previewInput: (input) => `${input.title} @ ${input.startsAt}`,
14+ }),
15+ list: defineTool({
16+ kind: "read",
17+ approval: "auto",
18+ run: () => Effect.sync(() => calendarStore.list()),
19+ }),
20+ },
21+ };
22+}
bun.lock+73−2
@@ -17,6 +17,8 @@ · 16 unmodified lines
1717 "name": "@openassistant/discord-bot",
1818 "dependencies": {
1919 "@ai-sdk/anthropic": "^2.0.0",
20+ "@effect/platform": "latest",
21+ "@effect/rpc": "latest",
2022 "@openassistant/core": "workspace:*",
2123 "@openassistant/reacord": "workspace:*",
2224 "ai": "^6.0.73",
@@ -32,13 +34,20 @@ · 9 unmodified lines
3234 "apps/gateway": {
3335 "name": "@openassistant/gateway",
3436 "dependencies": {
37+ "@ai-sdk/anthropic": "^2.0.0",
38+ "@effect/platform": "latest",
39+ "@effect/platform-bun": "latest",
40+ "@effect/rpc": "latest",
3541 "@openassistant/core": "workspace:*",
42+ "ai": "^6.0.73",
3643 "effect": "latest",
44+ "zod": "^4.3.6",
3745 },
3846 },
3947 "packages/core": {
4048 "name": "@openassistant/core",
4149 "dependencies": {
50+ "@effect/rpc": "latest",
4251 "effect": "latest",
4352 },
4453 },
@@ -58,6 +67,22 @@ · 13 unmodified lines
5867 },
5968 },
6069 },
70+ "overrides": {
71+ "@ai-sdk/anthropic": "^2.0.0",
72+ "@effect-atom/atom": "^0.4.11",
73+ "@effect-atom/atom-react": "^0.4.4",
74+ "@effect/platform": "latest",
75+ "@effect/platform-bun": "latest",
76+ "@effect/rpc": "latest",
77+ "@types/react": "^19.0.0",
78+ "@types/react-reconciler": "^0.31.0",
79+ "ai": "^6.0.73",
80+ "discord.js": "^14.17.3",
81+ "effect": "latest",
82+ "react": "^19.0.0",
83+ "react-reconciler": "^0.31.0",
84+ "zod": "^4.3.6",
85+ },
6186 "packages": {
6287 "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.59", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s7T5etVW7k4y3p1TDpTGa7xBddvSPWTvRa+EHAI0MPCrVh2dVFmAk+Dm6I8zlr1vz45SBpHvKR3SP29aGXdrUQ=="],
6388
@@ -83,16 +108,26 @@ · 19 unmodified lines
83108
84109 "@effect-atom/atom-react": ["@effect-atom/atom-react@0.4.6", "", { "dependencies": { "@effect-atom/atom": "^0.4.13" }, "peerDependencies": { "effect": "^3.19", "react": ">=18 <20", "scheduler": "*" } }, "sha512-ZvncuSQsCcUrsddD00JcKDMraPge0AiDMGiJqKc7ProvagCVrxB+Emy5uHM0l0SLGUx4X56bFE0d1tp9Z/Bf+g=="],
85110
111+ "@effect/cluster": ["@effect/cluster@0.56.2", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.3", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16" } }, "sha512-BtYorswZ0MlaTqa7k+57AD80gp/TJfej85vx0gDnIxhbPQ3+xoa3EXlPrfLV7XsiAokOxE5qs/X6UoZtAVeKug=="],
112+
86113 "@effect/experimental": ["@effect/experimental@0.57.11", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.93.6", "effect": "^3.19.9", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-M5uug3Drs/gyTHLfA+XzcIZQGUEV/Jn5yi1POki4oZswhpzNmsVTHl4THpxAordRKwa5lFvTSlsRP684YH7pSw=="],
87114
88115 "@effect/language-service": ["@effect/language-service@0.73.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-/SGoq50VDm/XwQI6d0cReYcLjwfdqwZv7uEJp92+ssx5vLCsm+QvHf4Ul6l6PYVzorsgAp/b6fhAwI2VSkqcJQ=="],
89116
90− "@effect/platform": ["@effect/platform@0.93.8", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.12" } }, "sha512-xTEy6fyTy4ijmFC3afKgtvYtn/JyPoIov4ZSUWJZUv3VeOcUPNGrrqG6IJlWkXs3NhvSywKv7wc1kw3epCQVZw=="],
117+ "@effect/platform": ["@effect/platform@0.94.3", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-bvTR8xLQoRpKgHuprZDOeQdPkhyVw+WT05iI9jl2s8Qiblyk5Dz2JLwJU+EFeksIBaPYz49xa635Om91T1CefQ=="],
118+
119+ "@effect/platform-bun": ["@effect/platform-bun@0.87.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-I88d0YqWbvLY2GGeIxK3r5k0l/MoUCCnxiHJG+X6gqaHu+pIs0djDtJ+ORhw/3qha9ojcVu6pyaBmnUjgzQHWQ=="],
120+
121+ "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="],
91122
92− "@effect/rpc": ["@effect/rpc@0.72.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.93.3", "effect": "^3.19.5" } }, "sha512-BmTXybXCOq96D2r9mvSW/YdiTQs5CStnd4II+lfVKrMr3pMNERKLZ2LG37Tfm4Sy3Q8ire6IVVKO/CN+VR0uQQ=="],
123+ "@effect/rpc": ["@effect/rpc@0.73.0", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg=="],
124+
125+ "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="],
93126
94127 "@effect/vitest": ["@effect/vitest@0.27.0", "", { "peerDependencies": { "effect": "^3.19.0", "vitest": "^3.2.0" } }, "sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ=="],
95128
129+ "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="],
130+
96131 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
97132
98133 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -169,6 +204,34 @@ · 70 unmodified lines
169204
170205 "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
171206
207+ "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
208+
209+ "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
210+
211+ "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
212+
213+ "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
214+
215+ "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
216+
217+ "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
218+
219+ "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
220+
221+ "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
222+
223+ "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
224+
225+ "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
226+
227+ "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
228+
229+ "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
230+
231+ "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
232+
233+ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
234+
172235 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
173236
174237 "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
@@ -323,10 +386,16 @@ · 148 unmodified lines
323386
324387 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
325388
389+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
390+
391+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
392+
326393 "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
327394
328395 "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
329396
397+ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
398+
330399 "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
331400
332401 "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
@@ -347,6 +416,8 @@ · 14 unmodified lines
347416
348417 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
349418
419+ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
420+
350421 "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
351422
352423 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
package.json+17−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 "test": "vitest run",
1717 "test:watch": "vitest",
1818 "clean": "bun run --filter '@openassistant/core' clean && bun run --filter '@openassistant/reacord' clean && bun run --filter '@openassistant/gateway' clean && bun run --filter '@openassistant/discord-bot' clean",
19+ "dev": "bun run dev:gateway & bun run dev:discord & wait",
1920 "dev:gateway": "bun run --cwd apps/gateway dev",
2021 "dev:discord": "bun run --cwd apps/discord-bot dev"
2122 },
@@ -26,5 +27,21 @@ · 4 unmodified lines
2627 "@typescript/native-preview": "7.0.0-dev.20260205.1",
2728 "typescript": "latest",
2829 "vitest": "^3.2.0"
30+ },
31+ "overrides": {
32+ "@ai-sdk/anthropic": "^2.0.0",
33+ "@effect/platform": "latest",
34+ "@effect/platform-bun": "latest",
35+ "@effect/rpc": "latest",
36+ "@effect-atom/atom": "^0.4.11",
37+ "@effect-atom/atom-react": "^0.4.4",
38+ "@types/react": "^19.0.0",
39+ "@types/react-reconciler": "^0.31.0",
40+ "ai": "^6.0.73",
41+ "discord.js": "^14.17.3",
42+ "effect": "latest",
43+ "react": "^19.0.0",
44+ "react-reconciler": "^0.31.0",
45+ "zod": "^4.3.6"
2946 }
3047 }
packages/core/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "clean": "bun -e \"import { rmSync } from 'node:fs'; for (const p of ['dist','node_modules','.turbo','coverage','.tsbuildinfo']) rmSync(p,{recursive:true,force:true});\""
1313 },
1414 "dependencies": {
15+ "@effect/rpc": "latest",
1516 "effect": "latest"
1617 }
1718 }
packages/core/src/agent-rpc.ts+24−0
@@ -0,0 +1,24 @@
1+import { Rpc, RpcGroup } from "@effect/rpc";
2+import { Schema } from "effect";
3+
4+export class RunTurnInput extends Schema.Class<RunTurnInput>("RunTurnInput")({
5+ prompt: Schema.String,
6+ requesterId: Schema.String,
7+ channelId: Schema.String,
8+ nowIso: Schema.String,
9+}) {}
10+
11+export class RunTurnOutput extends Schema.Class<RunTurnOutput>("RunTurnOutput")({
12+ message: Schema.String,
13+ planner: Schema.String,
14+ codeRuns: Schema.Number,
15+ footer: Schema.optional(Schema.String),
16+}) {}
17+
18+export class AgentRpcs extends RpcGroup.make(
19+ Rpc.make("RunTurn", {
20+ payload: RunTurnInput,
21+ success: RunTurnOutput,
22+ error: Schema.String,
23+ }),
24+) {}
packages/core/src/index.ts+1−0
@@ -2,3 +2,4 @@ import { Effect } from "effect"; · 1 unmodified line
22
33 export const healthcheck = Effect.succeed("ok");
44 export * from "./codemode/runner.js";
5+export * from "./agent-rpc.js";