Skip to main content
Version: 2.6

Building Reaktors and Aktors: A Comprehensive Guide

This guide provides a comprehensive overview of how to build reaktors and aktors in the Operaide system. It covers how to get started with existing templates, the different ways to create aktors, when to use each approach, and how to compose aktors into reaktors.

Getting Started with Reaktors

Starting from a Template

The easiest way to get started is by using one of the available templates and apps. They already contain the necessary scaffold to start your development.

  1. Head to the App Store inside the Operaide Platform and select one of the available apps that most closely matches the type of reaktor you want to build.

    App Store

  2. Click on the Install Button to download and install the application in Operaide.

  3. Once done, head to the Reaktor AI Engine -> Apps, where you should see your newly installed app.

    Reaktor AI Engine Apps

  4. From here, you have the option to download the whole app to make any adjustments or use it as a starter for your own development.

  5. When you press the download button, we create a preconfigured bundle, which contains config to deploy it back into the system where you downloaded it from.

  6. Open this bundle in your favorite IDE, read the included README.md files and get started with your development. Don't forget to change the app name in the package.json, so your app shows up with an appropriate name.

To see how you can deploy this custom Reaktor, see our Reaktor Deployment Gudie.

Introduction to Aktors and Reaktors

What is an Aktor?

Aktor Documentation

An Aktor is a fundamental building block in the Operaide system. It represents a computation that can be composed with other aktors to form complex workflows. Aktors have the following key characteristics:

  • Lazy Evaluation: Aktors compute their values only when needed.
  • Caching: Once computed, an aktor's value is cached and reused.
  • Composability: Aktors can be composed together to form complex computations.
  • Type Safety: Aktors are strongly typed using TypeScript.
  • Streaming Support: Some aktors can produce streams of values over time.

At its core, an Aktor is an object that implements the Aktor<TValue, TStream> interface, which provides two main methods:

interface Aktor<TValue = unknown, TStream = unknown> {
get(): Promise<TValue>;
getStream(): AsyncIterable<TStream>;
}
  • get(): Returns a promise that resolves to the final, aggregated value.
  • getStream(): Returns an async iterator that yields incremental stream elements.

What is a Reaktor?

Reaktor Documentation

A Reaktor is a registered, deployable composition of Aktors that can be executed as a unit. Reaktors are the entry points for workflows in the Operaide system. They have the following characteristics:

  • Registration: Reaktors are registered with the system using registerReaktorDefinition.
  • Input/Output Schema: Reaktors define their input and output schemas using Zod.
  • Metadata: Reaktors include metadata like label, description, and summary.
  • Composition: Reaktors are composed of a tree of Aktors.
  • Deployment: Reaktors can be deployed and executed through the Operaide platform.

A Reaktor is essentially a factory that creates an Aktor tree based on input parameters. The createReaktor method in a Reaktor definition takes an AktorMap of input parameters and returns an Aktor that represents the entire computation.

Creating Aktors

There are multiple ways to create aktors in the Operaide system, each with its own use cases and advantages. Let's explore each approach.

1. Using defineAktor

The defineAktor function is the most low-level and flexible way to create an aktor. It gives you full control over the aktor's behavior, including how it computes its value and how it handles streaming.

import type { Aktor } from '@operaide/aktor';
import { defineAktor } from '@operaide/aktor';

function aktorExample(input: {
// Input parameters as Aktors
param1: Aktor<string>;
param2: Aktor<number>;
}) {
return defineAktor(
'aktorExample', // Name of the aktor (must start with 'aktor')
{
// Options for the aktor
async getValue(params) {
// Logic to compute the value
const param1Value = await params.param1.get();
const param2Value = await params.param2.get();
return `${param1Value}: ${param2Value}`;
},
async *getValueStream(params) {
// Logic to produce a stream of values
const param1Value = await params.param1.get();
const param2Value = await params.param2.get();
yield `${param1Value}: ${param2Value} (part 1)`;
yield `${param1Value}: ${param2Value} (part 2)`;
},
},
input // Input parameters
);
}

Use defineAktor when:

  • You need full control over how the aktor computes its value
  • You need to implement custom streaming behavior
  • You're creating a complex aktor that can't be easily expressed as a pure function
  • You need to compose multiple aktors in a custom way

2. Using createAktorFunction

The createAktorFunction is a helper method that makes it easy to create aktors from pure functions. It's simply a wrapper to quickly define aktors without having to write all the boilerplate code. It handles resolving the aktor inputs to plain values before passing them to the pure function.

import { aktorConst, createAktorFunction } from '@operaide/aktor';

// Define a pure function
function sum({ x, y }: { x: number; y: number }): number {
return x + y;
}

// Create an aktor factory for the function
const aktorSum = createAktorFunction('aktorSum', sum);

// Use the factory to create an aktor
const sumAktor = aktorSum({
x: aktorConst(10),
y: aktorConst(20),
});

Use createAktorFunction when:

  • You have a pure, synchronous function that you want to use with aktors
  • Your function takes plain values (not aktors) as input
  • You don't need custom streaming behavior
  • You want a quick, concise way to define an aktor without writing boilerplate

3. Using createAktorFunctionAsync

The createAktorFunctionAsync is similar to createAktorFunction, but it's designed for asynchronous functions that return promises. It's also a helper method that serves as a wrapper to quickly define aktors for async functions.

import { aktorConst, createAktorFunctionAsync } from '@operaide/aktor';

// Define an async function
async function fetchData({ url }: { url: string }): Promise<any> {
const response = await axios.get(url);
return response.data;
}

// Create an aktor factory for the async function
const aktorFetchData = createAktorFunctionAsync('aktorFetchData', fetchData);

// Use the factory to create an aktor
const dataAktor = aktorFetchData({
url: aktorConst('https://api.example.com/data'),
});

Use createAktorFunctionAsync when:

  • You have an asynchronous function that returns a promise
  • Your function takes plain values (not aktors) as input
  • You don't need custom streaming behavior
  • You want a quick, concise way to define an aktor without writing boilerplate

4. Using createAktorMap

The createAktorMap function is a utility for converting a plain object into an object where each value is an aktor.

import { createAktorMap, aktorConst } from '@operaide/aktor';

// Create a plain object
const obj = {
name: 'John',
age: 30,
};

// Convert it to an AktorMap
const aktorMap = createAktorMap(obj, aktorConst);

Use createAktorMap when:

  • You have a plain object that you want to convert to an object of aktors
  • You want to apply the same aktor factory to each value in the object

Decision Tree for Creating Aktors

When deciding which approach to use for creating aktors, consider the following decision tree:

  1. Do you need to convert a plain object to an object of aktors?

    • Yes: Use createAktorMap
    • No: Continue to step 2
  2. Are you working with a function that takes plain values as input?

    • Yes: Continue to step 3
    • No: Use defineAktor
  3. Is your function asynchronous (returns a Promise)?

    • Yes: Use createAktorFunctionAsync
    • No: Use createAktorFunction
  4. Do you need custom streaming behavior?

    • Yes: Use defineAktor
    • No: Continue with your choice from step 3
  5. Do you need complex composition of multiple aktors?

    • Yes: Use defineAktor
    • No: Continue with your choice from step 3 or 4

Creating Reaktors

A Reaktor is a registered, deployable composition of Aktors. Here's how to create a Reaktor:

  1. Create a new TypeScript file with a .reaktor.ts extension (this is conventional but not required).
  2. Import the necessary dependencies:
import { z } from 'zod';
import { registerReaktorDefinition } from '@operaide/aktor';
  1. Define your Reaktor using registerReaktorDefinition:
registerReaktorDefinition({
// Metadata
reaktorDefinitionId: 'my-unique-reaktor-id',
label: 'My Reaktor',
description: 'A description of what my reaktor does',
summary: 'Optional summary',

// Input schema
inputSchema: z.object({
param1: z.string().openapi({ example: 'example value' }),
param2: z.number().openapi({ example: 42 }),
}),

// (Optional) Streaming schema
streamSchema: z.any(),

// Output schema
outputSchema: z.object({
result: z.string(),
}),

// Create the reaktor
createReaktor(params) {
// Compose aktors to form the reaktor
return aktorExample({
param1: params.param1,
param2: params.param2,
});
},
});

The createReaktor method is where you compose aktors to form the reaktor. It takes an AktorMap of input parameters and returns an Aktor that represents the entire computation.

UI Schema Tags

When defining input schemas for Reaktors, you can use special tags in the .openapi() description to customize how fields are rendered in the UI. These tags are processed by the schema transformation system to provide enhanced UI components.

Available UI Tags

File Upload Tag: [file-upload]

The [file-upload] tag transforms a string field into a file upload component in the UI. This is particularly useful for Reaktors that need to process files like images, documents, or audio files.

inputSchema: z.object({
document: z.string().openapi({
description: '[file-upload] Upload a PDF document for analysis',
example: 'data:application/pdf;base64,JVBERi0xLjQKJcfs...'
}),
image: z.string().openapi({
description: '[file-upload] Upload an image (PNG, JPEG, etc.)',
example: 'data:image/png;base64,iVBORw0KGgoAAAANS...'
}),
})

When the [file-upload] tag is detected:

  • The UI renders a file upload component instead of a text input
  • Files are automatically converted to base64 data URLs
  • The resulting string value follows the format: data:[mime-type];base64,[base64-encoded-content]
Textarea Tag: [textarea]

The [textarea] tag transforms a string field into a multi-line text area in the UI. This is useful for fields that expect longer text content like prompts, descriptions, or document content.

inputSchema: z.object({
prompt: z.string().openapi({
description: '[textarea] Enter your detailed prompt here',
example: 'Analyze the following document and provide a summary...'
}),
notes: z.string().openapi({
description: '[textarea] Additional notes or context',
example: 'These are some notes that might span multiple lines...'
}),
})

When the [textarea] tag is detected:

  • The UI renders a multi-line textarea instead of a single-line input
  • Users can enter text with line breaks and formatting
  • The textarea automatically expands to accommodate content

Best Practices for UI Tags

  1. Place tags at the beginning: Always place UI tags at the start of the description, followed by a space
  2. Combine with descriptive text: Include helpful description after the tag to guide users
  3. Use appropriate examples: Provide realistic examples that match the expected format
  4. Consider the data format: Remember that file uploads result in base64 data URLs

Example of a complete Reaktor using UI tags:

registerReaktorDefinition({
reaktorDefinitionId: 'document-analyzer',
label: 'Document Analyzer',
description: 'Analyzes uploaded documents with custom instructions',
inputSchema: z.object({
document: z.string().openapi({
description: '[file-upload] Upload a document (PDF, DOCX, TXT)',
example: 'data:application/pdf;base64,JVBERi0xLjQK...'
}),
instructions: z.string().openapi({
description: '[textarea] Provide analysis instructions',
example: 'Please analyze this document for:\n- Key themes\n- Summary points\n- Action items'
}),
outputFormat: z.enum(['summary', 'detailed', 'bullet-points']).openapi({
description: 'Choose the output format',
example: 'summary'
})
}),
outputSchema: z.object({
analysis: z.string(),
confidence: z.number()
}),
createReaktor(params) {
// Implementation here
}
});

The Concept Behind Reaktors

Reaktors are designed around the concept of an Abstract Syntax Tree (AST) of computations. Each node in the tree is an Aktor, and the tree as a whole represents a complex computation.

The key advantages of this design are:

  1. Lazy Evaluation: Computations are only performed when their results are needed.
  2. Caching: Results are cached and reused, avoiding redundant computations.
  3. Parallelism: Independent branches of the tree can be computed in parallel.
  4. Instrumentalization: The computation tree can be inspected, logged, and monitored.
  5. Serialization: The tree can be serialized and deserialized, allowing computations to be distributed across machines.

When a Reaktor is executed, the system:

  1. Creates an Aktor tree based on the input parameters.
  2. Traverses the tree to compute the final result.
  3. Caches intermediate results to avoid redundant computations.
  4. Returns the final result according to the output schema.

This design allows for complex, efficient, and maintainable workflows.

Examples

Example 1: Simple Reaktor with a Single Aktor

import { z } from 'zod';
import { createAktorFunction, registerReaktorDefinition } from '@operaide/aktor';

// Define a pure function
function multiply({ a, b }: { a: number; b: number }): number {
return a * b;
}

// Create an aktor factory
const aktorMultiply = createAktorFunction('aktorMultiply', multiply);

// Register the reaktor
registerReaktorDefinition({
reaktorDefinitionId: 'simple-multiply-example',
label: 'Multiply Reaktor',
description: 'Multiplies two numbers',
inputSchema: z.object({
a: z.number().openapi({ example: 2 }),
b: z.number().openapi({ example: 3 }),
}),
outputSchema: z.number(),
createReaktor(params) {
return aktorMultiply({
a: params.a,
b: params.b,
});
},
});

Example 2: Complex Reaktor with Multiple Aktors

import axios from 'axios';
import { z } from 'zod';
import type { Aktor } from '@operaide/aktor';
import { createAktorFunctionAsync, defineAktor, registerReaktorDefinition } from '@operaide/aktor';

// Async function to fetch weather data
async function getWeather({ city }: { city: string }): Promise<any> {
const response = await axios.get(`https://api.example.com/weather?city=${encodeURIComponent(city)}`);
return response.data;
}

// Create an aktor factory
const aktorGetWeather = createAktorFunctionAsync('aktorGetWeather', getWeather);

// Aktor to format the weather data
function aktorFormatWeather(input: { weather: Aktor<any> }) {
return defineAktor(
'aktorFormatWeather',
{
async getValue({ weather }) {
const data = await weather.get();
return {
city: data.city,
temperature: data.temperature,
conditions: data.conditions,
};
},
},
input
);
}

// Register the reaktor
registerReaktorDefinition({
reaktorDefinitionId: 'weather-example',
label: 'Weather Reaktor',
description: 'Gets weather information for a city',
inputSchema: z.object({
city: z.string().openapi({ example: 'New York' }),
}),
outputSchema: z.object({
city: z.string(),
temperature: z.number(),
conditions: z.string(),
}),
createReaktor(params) {
// Get the weather data
const weather = aktorGetWeather({
city: params.city,
});

// Format the weather data
return aktorFormatWeather({
weather,
});
},
});

Visualizing Reaktor Structure

When creating a Reaktor for a more complex workflow, it's important to think about the structure of the Reaktor. We recommend making a simple sketch of your workflow as a tree structure. This will help you understand the dependencies between the Aktors and how to compose them.

Here's an example of a Reaktor structure visualization:

This diagram shows a typical Reaktor structure where:

  • Blue nodes represent complex Aktors that may contain nested Aktors
  • Pink nodes represent AktorFunctions that perform simple operations

Visualizing your Reaktor structure before implementation can help you:

  • Identify reusable components
  • Understand data flow
  • Plan for error handling
  • Optimize performance

Best Practices

  1. Naming Conventions:

    • Aktor names should start with aktor (e.g., aktorMultiply).
    • Reaktor files should have a .reaktor.ts extension.
    • Use descriptive names that indicate what the aktor or reaktor does.
  2. Composition:

    • Break down complex computations into smaller, reusable aktors.
    • Compose aktors to form more complex computations.
    • Use the appropriate aktor creation method based on your needs.
  3. Error Handling:

    • Use the retryOnErrorInMilliseconds and maxRetries options for aktors that might fail.
    • Handle errors gracefully and provide meaningful error messages.
  4. Performance:

    • Leverage the caching behavior of aktors to avoid redundant computations.
    • Use aktorParallel for independent computations that can be run in parallel.
  5. Documentation:

    • Provide clear descriptions and summaries for your reaktors.
    • Document the input and output schemas with examples.

Deploying Reaktors

After you've built your Reaktor, you'll need to deploy it to make it available for use. Here's a brief overview of the deployment process:

  1. Install Dependencies: If you're working with a downloaded Blueprint, make sure to install all dependencies:

    npm install
  2. Deploy the Reaktor: Use the deploy command to deploy your Reaktor:

    npm run deploy
  3. Watch for Changes: During development, you can use the watch command to automatically redeploy when changes are made:

    npm run deploy-watch
  4. Access Your Reaktor: Once deployed, you can access your Reaktor through:

    • The Operaide UI in the Reaktor AI Engine section
    • The REST API using your API key

For more detailed information about deploying Reaktors, including working with Blueprints and the App Store, refer to the Reaktor Deployment documentation.

Conclusion

Aktors and Reaktors provide a powerful framework for building complex, efficient, and maintainable workflows. By understanding the different ways to create aktors and how to compose them into reaktors, you can leverage the full power of the Operaide system.

This guide has covered:

  • Getting started with templates
  • The fundamentals of Aktors and Reaktors
  • Different ways to create Aktors
  • A decision tree for choosing the right approach
  • How to create Reaktors
  • Visualizing Reaktor structure
  • Examples and best practices
  • Deploying Reaktors

For more information, refer to the API documentation and other guides in the Operaide documentation: