# Dxos Operations

> Guide for defining and implementing Operations in DXOS. Use when creating operation definitions, writing handlers, structuring operation modules, using OperationHandlerSet, or migrating from the old FunctionDefinition API.

- Skill: `dxos/dxos-operations` (Agent Skill)
- Install (CLI): `npx skillmds@latest add dxos/dxos-operations`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dxos/dxos-operations/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: dxos (https://skillmd.com/u/dxos)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dxos/dxos-operations

---


# DXOS Operations

## What is an Operation?

An Operation is the unit of callable logic in DXOS. It combines a **definition** (typed schema + metadata) with a **handler** (Effect-based runtime logic). Operations replace the older `FunctionDefinition` / `defineFunction` API.

Key properties:

- **Type-safe**: Input and output are `effect/Schema` types.
- **Effect-native**: Handlers return `Effect.Effect`, enabling composable error handling, service injection, and concurrency.
- **Serializable definitions**: Definitions carry no runtime logic and can be shared across packages, serialized to ECHO, or sent over the wire.
- **Lazy-loadable handlers**: Handler modules use dynamic `import()` so code is loaded only when invoked.

Import: `import { Operation, OperationHandlerSet } from '@dxos/compute';`

## Naming

- Definitions: use a plain name without a `Definition` suffix (e.g. `ReadName`, `Fibonacci`).
- Handlers: may add a `Handler` suffix (e.g. `ReadNameHandler`).

## Defining an Operation

Use `Operation.make` to create a definition. Definitions live in a separate file from handlers.

```ts
import { Operation } from '@dxos/compute';
import * as Schema from 'effect/Schema';

export const MyOperation = Operation.make({
  meta: {
    key: 'com.example/operation/my-operation',
    name: 'MyOperation',
    description: 'Does something useful',
  },
  input: Schema.Struct({
    value: Schema.Number,
  }),
  output: Schema.Struct({
    result: Schema.String,
  }),
  services: [Database.Service],
});
```

### `Operation.make` options

| Field              | Required | Default   | Description                                            |
| ------------------ | -------- | --------- | ------------------------------------------------------ |
| `meta.key`         | yes      | —         | Globally unique key (reverse-domain style).            |
| `meta.name`        | no       | —         | Human-readable name.                                   |
| `meta.description` | no       | —         | Short description.                                     |
| `input`            | yes      | —         | `effect/Schema` for the input payload.                 |
| `output`           | yes      | —         | `effect/Schema` for the return value.                  |
| `executionMode`    | no       | `'async'` | `'sync'` or `'async'`.                                 |
| `types`            | no       | `[]`      | ECHO types the operation uses (registered at runtime). |
| `services`         | no       | `[]`      | Effect `Context.Tag`s required by the handler.         |

## Writing a Handler

There are two patterns for attaching handlers, depending on whether the handler needs to be deployable to EDGE.

### Handler module layout (deployable handler files)

For each handler module (e.g. `sync.ts`, `handler-a.ts`):

1. **Imports first** (project import order: builtin → external → `@dxos` → internal).
2. **`export default` immediately after imports** — `Definition.pipe(Operation.withHandler(...))` so the entry point is obvious when opening the file.
3. **Everything else below** — private constants, file-local types, and helper functions used by the handler. Handlers run only after the module has finished loading, so closures may reference those bindings safely.
4. **Only the default export** — do not add named exports from handler modules. Share types and operation definitions from `definitions.ts` (or other modules), not from handler files.

Small handlers with no helpers are just imports + default export.

Reference: `packages/plugins/plugin-script/src/skills/functions/deploy.ts`

### Pattern 1: Deployable handlers (one file per handler)

Use this when handlers may be deployed to EDGE. Each handler file default-exports the definition piped through `Operation.withHandler`. The barrel `index.ts` uses `OperationHandlerSet.keyed(...)`, which pairs each (lightweight) definition with its handler module so the framework loads exactly the invoked operation's module.

```ts
// handler-a.ts
import * as Effect from 'effect/Effect';
import { Operation } from '@dxos/compute';
import { MyOperation } from './definitions';

export default MyOperation.pipe(
  Operation.withHandler(
    Effect.fn(function* ({ value }) {
      return { result: formatResult(value) };
    }),
  ),
);

const formatResult = (value: number) => String(value * 2);
```

When the handler needs helpers, keep the `export default` **at the top** (after imports) and add private helpers **below**, as with `formatResult` here.

```ts
// index.ts
import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';

import * as MyOperation from '../types/MyOperation';

export const MyHandlers = OperationHandlerSet.keyed([
  [MyOperation.A, () => import('./handler-a')],
  [MyOperation.B, () => import('./handler-b')],
]);
```

### Pattern 2: Inline handler set (tests, local-only code)

When handlers don't need individual files (e.g. tests, local-only logic), create the `OperationHandlerSet` directly with `OperationHandlerSet.make(...)`. No need to define individual handler variables — build the set inline:

```ts
import { Operation, OperationHandlerSet } from '@dxos/compute';

const Handlers = OperationHandlerSet.make(
  Operation.withHandler(
    ReadName,
    Effect.fnUntraced(function* ({ org }) {
      const resolved = yield* Database.load(org);
      return resolved.name ?? '<no org>';
    }),
  ),
);
```

### Handler examples

**Simple handler** (no services):

```ts
export default MyOp.pipe(
  Operation.withHandler(
    Effect.fn(function* (input) {
      return { result: process(input) };
    }),
  ),
);
```

**Handler with Effect services**:

```ts
export default MyOp.pipe(
  Operation.withHandler(
    Effect.fn(function* (input) {
      const db = yield* Database.Service;
      // use db...
      return { result: 'ok' };
    }),
  ),
);
```

Note: Services need to be explicitly listed in the operation definition.

### Preferred handler form: typed const

Existing plugin handlers (plugin-chess, plugin-space, plugin-bookmarks) preempt TS2742 by annotating a const with the definition's handler type instead of piping `opaqueHandler`:

```ts
const handler: Operation.WithHandler<typeof MyOp> = MyOp.pipe(
  Operation.withHandler(Effect.fn(function* (input) { ... })),
);

export default handler;
```

Prefer this form for new handlers; reach for `opaqueHandler` only when the annotation itself cannot be named.

### Troubleshooting: TS2742 on `export default`

If the build fails with **TS2742** ("The inferred type of 'default' cannot be named without a reference to ..."), it means the handler's inferred type includes service tags (e.g. `TraceService`) whose module path isn't directly imported in the handler file. Fix this by piping through `Operation.opaqueHandler` as the last step, which erases the complex service types to `Operation.WithHandler<Definition.Any>`:

```ts
export default MyOp.pipe(
  Operation.withHandler(
    Effect.fn(function* (input) { ... }),
  ),
  Operation.opaqueHandler,
);
```

## File Structure

For deployable operations, follow this layout (see `packages/core/functions/src/example/` for reference):

```
my-operations/
├── definitions.ts      # All Operation.make() definitions (no handlers)
├── handler-a.ts        # Default-exports definition with handler attached
├── handler-b.ts        # One handler per file
└── index.ts            # Re-exports definitions + creates OperationHandlerSet
```

### `definitions.ts` — Pure definitions, no runtime logic

```ts
import { Operation } from '@dxos/compute';
import * as Schema from 'effect/Schema';

export const OpA = Operation.make({
  meta: { key: 'com.example/op-a', name: 'OpA' },
  input: Schema.Struct({ n: Schema.Number }),
  output: Schema.Struct({ result: Schema.String }),
});

export const OpB = Operation.make({
  meta: { key: 'com.example/op-b', name: 'OpB' },
  input: Schema.Any,
  output: Schema.Any,
});
```

### `handler-a.ts` — Single handler, default export only

Put `export default OpA.pipe(Operation.withHandler(...))` right after imports. If you add helpers, place them below the default export and do not export them.

```ts
import * as Effect from 'effect/Effect';
import { Operation } from '@dxos/compute';
import { OpA } from './definitions';

export default OpA.pipe(
  Operation.withHandler(
    Effect.fn(function* ({ n }) {
      return { result: formatResult(n) };
    }),
  ),
);

const formatResult = (n: number) => String(n);
```

### `index.ts` — Barrel with lazy handler set

Export definitions and create a keyed handler set. See `packages/plugins/plugin-markdown/src/skills/functions/` for reference.

```ts
import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';

import * as MyOperation from '../types/MyOperation';

export const MyHandlers = OperationHandlerSet.keyed([
  [MyOperation.A, () => import('./handler-a')],
  [MyOperation.B, () => import('./handler-b')],
]);
```

For skills: pass the definitions to `Skill.toolDefinitions({ operations: [Create, Open, Update] })`, and pass `MyHandlers` to the skill definition's `operations` field.

## OperationHandlerSet

Groups handlers for registration with the runtime. The type for a handler set is `OperationHandlerSet.OperationHandlerSet`.

Whenever you need to pass around a collection of handlers (test layers, registration, etc.), use `OperationHandlerSet.OperationHandlerSet` as the type — never `Operation.WithHandler<...>[]`.

| Factory                          | Use case                                                                   |
| -------------------------------- | -------------------------------------------------------------------------- |
| `OperationHandlerSet.keyed(...)` | Pair each definition with its handler module; loads per invoked operation. |
| `OperationHandlerSet.make(...)`  | Wrap already-resolved handlers.                                            |
| `OperationHandlerSet.merge(...)` | Combine multiple sets into one.                                            |

```ts
import { OperationHandlerSet } from '@dxos/compute';

// Merge multiple sets.
const AllHandlers = OperationHandlerSet.merge(FeatureAHandlers, FeatureBHandlers);

// Wrap resolved handlers (e.g. for tests).
const TestHandlers = OperationHandlerSet.make(MyHandler, OtherHandler);

// Accept as a parameter.
const setup = (handlers: OperationHandlerSet.OperationHandlerSet) => { ... };
```

## Invoking Operations

Inside an Effect handler, use the `Operation.Service`:

```ts
// Invoke another operation
const result = yield * Operation.invoke(OtherOp, { data: 'hello' });

// Schedule a fire-and-forget followup
yield * Operation.schedule(AnalyticsOp, { event: 'completed' });
```

## Migration from FunctionDefinition

The `FunctionDefinition` / `defineFunction` API is replaced by `Operation`.

### Definition

**Before** (`defineFunction`):

```ts
import { defineFunction } from '@dxos/functions';
import * as Schema from 'effect/Schema';

export const myFunc = defineFunction({
  key: 'com.example/function/my-func',
  name: 'MyFunc',
  description: 'Does something',
  inputSchema: Schema.Struct({ value: Schema.Number }),
  outputSchema: Schema.Struct({ result: Schema.String }),
  handler: ({ data }) => {
    return { result: String(data.value) };
  },
});
```

**After** (`Operation.make` + `Operation.withHandler`):

```ts
// definitions.ts
import { Operation } from '@dxos/compute';
import * as Schema from 'effect/Schema';

export const MyFunc = Operation.make({
  meta: {
    key: 'com.example/function/my-func',
    name: 'MyFunc',
    description: 'Does something',
  },
  input: Schema.Struct({ value: Schema.Number }),
  output: Schema.Struct({ result: Schema.String }),
});
```

```ts
// my-func.ts
import * as Effect from 'effect/Effect';
import { Operation } from '@dxos/compute';
import { MyFunc } from './definitions';

export default MyFunc.pipe(
  Operation.withHandler(
    Effect.fn(function* ({ value }) {
      return { result: formatResult(value) };
    }),
  ),
);

const formatResult = (value: number) => String(value);
```

### Handler input

Operation handlers receive the input directly, not wrapped in `{ data }`:

```ts
// Old (FunctionDefinition) — input wrapped in { data, context }:
handler: ({ data: { a, b } }) => a + b;

// New (Operation) — input passed directly:
Operation.withHandler(
  Effect.fn(function* ({ a, b }) {
    return a + b;
  }),
);
```

### Key differences

| Aspect               | `FunctionDefinition`                                     | `Operation`                                                                       |
| -------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Import               | `@dxos/functions`                                        | `@dxos/operation`                                                                 |
| Create definition    | `defineFunction({ ... })`                                | `Operation.make({ ... })`                                                         |
| Schema fields        | `inputSchema` / `outputSchema`                           | `input` / `output`                                                                |
| Metadata             | Top-level `key`, `name`, `description`                   | Nested under `meta: { key, name, description }`                                   |
| Handler              | Inline `handler` property                                | Separate: `Operation.withHandler(handler)`                                        |
| Handler input        | `({ context, data }) => ...`                             | `Effect.fn(function* (input) { ... })`                                            |
| Handler return       | Plain value, Promise, or Effect                          | Always `Effect`                                                                   |
| Services             | `string[]` keys                                          | `Context.Tag[]` references                                                        |
| File structure       | Single file with definition + handler                    | Split: `definitions.ts` + per-handler files                                       |
| Type                 | `FunctionDefinition<I, O>`                               | `Operation.Definition<I, O>` or `Operation.WithHandler<Operation.Definition.Any>` |
| Persistent ECHO type | `Operation.PersistentOperation` (from `@dxos/functions`) | `Operation.PersistentOperation` (from `@dxos/operation`)                          |
| Handler set          | N/A                                                      | `OperationHandlerSet.keyed(...)`                                                  |

