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/Schematypes. - 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
Definitionsuffix (e.g.ReadName,Fibonacci). - Handlers: may add a
Handlersuffix (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):
- Imports first (project import order: builtin → external →
@dxos→ internal). export defaultimmediately after imports —Definition.pipe(Operation.withHandler(...))so the entry point is obvious when opening the file.- 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.
- 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(...) |