dxos-operations

star 507

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.

dxos By dxos schedule Updated 6/11/2026

name: dxos-operations description: >- 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.

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.

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.Tags 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 importsDefinition.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.lazy(...) to load them on demand.

// 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.

// index.ts
import { OperationHandlerSet } from '@dxos/compute';
export * from './definitions';

export const MyHandlers = OperationHandlerSet.lazy(
  () => import('./handler-a'),
  () => 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:

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):

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

Handler with Effect services:

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:

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>:

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

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.

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 lazy handler set. See packages/plugins/plugin-markdown/src/skills/functions/ for reference.

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

export * from './definitions';

export const MyHandlers = OperationHandlerSet.lazy(
  () => import('./handler-a'),
  () => 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.lazy(...) Lazy-load handler modules via dynamic import.
OperationHandlerSet.make(...) Wrap already-resolved handlers.
OperationHandlerSet.merge(...) Combine multiple sets into one.
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:

// 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):

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):

// 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 }),
});
// 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 }:

// 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.lazy(...)
Install via CLI
npx skills add https://github.com/dxos/dxos --skill dxos-operations
Repository Details
star Stars 507
call_split Forks 43
navigation Branch main
article Path SKILL.md
More from Creator