DXOS Operations

SkillDev tools

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the DXOS Operations skill

What this skill tells your AI

The instructions your AI receives, as published by dxos/dxos in .agents/skills/operations/SKILL.md and read by ahel’s review.

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

FieldRequiredDefaultDescription
meta.keyyesGlobally unique key (reverse-domain style).
meta.namenoHuman-readable name.
meta.descriptionnoShort description.
inputyeseffect/Schema for the input payload.
outputyeseffect/Schema for the return value.
executionModeno'async''sync' or 'async'.
typesno[]ECHO types the operation uses (registered at runtime).
servicesno[]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.keyed(...), which pairs each (lightweight) definition with its handler module so the framework loads exactly the invoked operation's module.

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

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

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<...>[].

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

AspectFunctionDefinitionOperation
Import@dxos/functions@dxos/operation
Create definitiondefineFunction({ ... })Operation.make({ ... })
Schema fieldsinputSchema / outputSchemainput / output
MetadataTop-level key, name, descriptionNested under meta: { key, name, description }
HandlerInline handler propertySeparate: Operation.withHandler(handler)
Handler input({ context, data }) => ...Effect.fn(function* (input) { ... })
Handler returnPlain value, Promise, or EffectAlways Effect
Servicesstring[] keysContext.Tag[] references
File structureSingle file with definition + handlerSplit: definitions.ts + per-handler files
TypeFunctionDefinition<I, O>Operation.Definition<I, O> or Operation.WithHandler<Operation.Definition.Any>
Persistent ECHO typeOperation.PersistentOperation (from @dxos/functions)Operation.PersistentOperation (from @dxos/operation)
Handler setN/AOperationHandlerSet.keyed(...)

Signals

GitHub stars
520
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
dxos-operations
Source
github.com/dxos/dxos