| title | First Simple Action |
|---|---|
| description | Build a complete action with a function, event, and data type from scratch |
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
This tutorial walks through building a complete action that:
- Exposes a
fibonaccifunction - Fires a
user_createdevent - Registers an
email_addressdata type
A RuntimeFunction contains the actual implementation.
<Tabs items={['TypeScript', 'Rust']}>
Decorate it with @Identifier, @Signature, and @Name, then implement the run() method.
// src/functions/fibonacciRuntimeFunction.ts
import {
Identifier,
Signature,
Name,
DisplayMessage,
Parameter,
OmitRuntimeFunction,
FunctionContext,
} from '@code0-tech/hercules';
@Identifier('fibonacci_runtime')
@Signature('(test: number): number')
@Name({ code: 'en-US', content: 'Fibonacci (Runtime)' })
@DisplayMessage({ code: 'en-US', content: 'Computes the n-th Fibonacci number' })
@OmitRuntimeFunction()
@Parameter({ runtimeName: 'test', name: [{ code: 'en-US', content: 'N' }] })
export class FibonacciRuntimeFunction {
run(context: FunctionContext, test: number): number {
console.log(`[fibonacci] project=${context.projectId} execution=${context.executionId}`);
return this.fib(test);
}
private fib(n: number): number {
if (n <= 1) return n;
return this.fib(n - 1) + this.fib(n - 2);
}
}@OmitRuntimeFunction() prevents Hercules from auto-generating a public function definition from this class — we'll
define that separately below.
Attach #[hercules::runtime_function(...)], giving it an identifier and signature, then implement
RuntimeFunctionHandler. Attaching the macro registers it automatically — there's no separate registration call to
make later.
// src/functions/fibonacci_runtime_function.rs
use hercules::{
async_trait, Arguments, FunctionContext, PlainValue, Result, RuntimeFunctionHandler,
};
#[hercules::runtime_function(
identifier = "fibonacci_runtime",
signature = "(test: NUMBER): NUMBER",
name(en_US = "Fibonacci (Runtime)"),
display_message(en_US = "Computes the n-th Fibonacci number"),
omit_definition
)]
#[parameter(runtime_name = "test", name(en_US = "N"))]
pub struct FibonacciRuntimeFunction;
#[async_trait]
impl RuntimeFunctionHandler for FibonacciRuntimeFunction {
async fn run(&self, context: &FunctionContext, args: &Arguments) -> Result<PlainValue> {
let n: u64 = args.get("test")?;
log::debug!(
"computing fibonacci({n}) for project={} execution={}",
context.project_id,
context.execution_id
);
Ok(fib(n).into())
}
}
fn fib(n: u64) -> u64 {
if n <= 1 {
n
} else {
fib(n - 1) + fib(n - 2)
}
}omit_definition prevents Hercules from auto-publishing this runtime function as its own public function — we'll
define that separately below.
A Function extends the RuntimeFunction and adds public-facing metadata without changing the implementation.
<Tabs items={['TypeScript', 'Rust']}>
// src/functions/fibonacciFunction.ts
import { Identifier, Name, Parameter } from '@code0-tech/hercules';
import { FibonacciRuntimeFunction } from './fibonacciRuntimeFunction.js';
@Identifier('fibonacci')
@Name({ code: 'en-US', content: 'Compute Fibonacci Number' })
@Parameter({
runtimeName: 'test',
name: [{ code: 'en-US', content: 'Input Number' }],
description: [{ code: 'en-US', content: 'The position in the Fibonacci sequence' }],
defaultValue: 10,
})
export class FibonacciFunction extends FibonacciRuntimeFunction {}// src/functions/fibonacci_function.rs
use super::fibonacci_runtime_function::FibonacciRuntimeFunction;
#[hercules::function(base = FibonacciRuntimeFunction, identifier = "fibonacci")]
#[parameter(
runtime_name = "test",
name(en_US = "Input Number"),
description(en_US = "The position in the Fibonacci sequence"),
default_value = 10
)]
pub struct FibonacciFunction;<Tabs items={['TypeScript', 'Rust']}>
// src/events/userCreatedRuntimeEvent.ts
import { Identifier, Signature, Name, EventSetting } from '@code0-tech/hercules';
@Identifier('user_created')
@Signature('(userId: number): void')
@Name({ code: 'en-US', content: 'User Created' })
@EventSetting({
identifier: 'FILTER_ROLE',
name: [{ code: 'en-US', content: 'Role Filter' }],
description: [{ code: 'en-US', content: 'Only trigger for users with this role' }],
optional: true,
})
export class UserCreatedRuntimeEvent {}<Tabs items={['TypeScript', 'Rust']}>
// src/events/userCreatedEvent.ts
import { Identifier, Name, Editable } from '@code0-tech/hercules';
import { UserCreatedRuntimeEvent } from './userCreatedRuntimeEvent.js';
@Identifier('user_created_event')
@Name({ code: 'en-US', content: 'On User Created' })
@Editable(false)
export class UserCreatedEvent extends UserCreatedRuntimeEvent {}#[hercules::event( base = UserCreatedRuntimeEvent, identifier = "user_created_event", name(en_US = "On User Created") )] pub struct UserCreatedEvent;
`editable` defaults to `false`, so there's nothing to set explicitly here — add the bare `editable` flag to turn it
on.
</Tab>
</Tabs>
## 5. Create the DataType
<Tabs items={['TypeScript', 'Rust']}>
<Tab value="TypeScript">
Data types are backed by a Zod schema. Decorate the class with `@Identifier`, `@Name`, and `@Schema`.
```ts
// src/data_types/emailDataType.ts
import { Identifier, Name, Schema } from '@code0-tech/hercules';
import { z } from 'zod';
const EmailSchema = z.string().regex(/^[^@]+@[^@]+\.[^@]+$/);
@Identifier('email_address')
@Name({ code: 'en-US', content: 'Email Address' })
@Schema(EmailSchema)
export class EmailDataType {}
// src/data_types/email_data_type.rs
use hercules::JsonSchema;
use serde::{Deserialize, Serialize};
#[hercules::data_type(identifier = "email_address", name(en_US = "Email Address"))]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct EmailAddress(#[schemars(regex(pattern = r"^[^@]+@[^@]+\.[^@]+$"))] pub String);<Tabs items={['TypeScript', 'Rust']}>
// src/index.ts
import { Action, CodeZeroEvent } from '@code0-tech/hercules';
import { FibonacciRuntimeFunction } from './functions/fibonacciRuntimeFunction.js';
import { FibonacciFunction } from './functions/fibonacciFunction.js';
import { UserCreatedRuntimeEvent } from './events/userCreatedRuntimeEvent.js';
import { UserCreatedEvent } from './events/userCreatedEvent.js';
import { EmailDataType } from './data_types/emailDataType.js';
const action = new Action(
process.env.ACTION_ID ?? 'example-action',
process.env.VERSION ?? '0.0.0',
process.env.AQUILA_URL ?? '127.0.0.1:8081',
'my-org',
'tabler:bolt',
'A simple example action',
[{ code: 'en-US', content: 'Example Action' }],
[
{
identifier: 'EXAMPLE_CONFIG',
type: 'string',
name: [{ code: 'en-US', content: 'Example Config' }],
},
],
);
action.registerRuntimeFunction(FibonacciRuntimeFunction);
action.registerFunction(FibonacciFunction);
action.registerDataTypeClass(EmailDataType);
action.registerRuntimeEventClass(UserCreatedRuntimeEvent);
action.registerEventClass(UserCreatedEvent);
action.on(CodeZeroEvent.connected, () => {
console.log('Connected to Aquila');
});
action.on(CodeZeroEvent.error, (error: Error) => {
console.error('Stream error:', error.message);
process.exit(1);
});
action.connect(process.env.AUTH_TOKEN ?? 'token').catch((err: unknown) => {
console.error('Failed to connect:', err);
process.exit(1);
});// src/main.rs
mod data_types;
mod events;
mod functions;
use hercules::{Action, ConfigurationDefinition, HerculesEvent, Translation};
use tokio_stream::StreamExt;
fn env(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn build_action() -> Action {
Action::new(env("ACTION_ID", "example-action"), env("VERSION", "0.0.0"))
.aquila_url(env("AQUILA_URL", "127.0.0.1:8081"))
.author("my-org")
.icon("tabler:bolt")
.documentation("A simple example action")
.name([Translation::new("en-US", "Example Action")])
.configuration(
ConfigurationDefinition::new("EXAMPLE_CONFIG", "string")
.name([Translation::new("en-US", "Example Config")]),
)
}
#[tokio::main]
async fn main() -> hercules::Result<()> {
let action = build_action();
let mut events = action.subscribe();
// The returned `Connected` handle can be discarded immediately: the
// background dispatch task (started inside `connect`) holds its own
// `Arc` to the shared connection state.
action
.connect(env("AUTH_TOKEN", "token"), None)
.await
.unwrap_or_else(|err| panic!("failed to connect to Aquila: {err}"));
// React to connection lifecycle events on the main task for as long as
// the process runs. Panicking here (unlike inside a spawned task) takes
// the whole process down on an unrecoverable stream error.
while let Some(event) = events.next().await {
match event {
HerculesEvent::Connected => log::info!("connected to Aquila"),
HerculesEvent::Error(error) => panic!("Aquila stream error: {error}"),
_ => {}
}
}
Ok(())
}To fire the user_created event from within your action (e.g. after a webhook call):
<Tabs items={['TypeScript', 'Rust']}>
await action.fire(UserCreatedRuntimeEvent, projectId, { userId: 42 });The second argument is the project ID this event belongs to, and the third is the payload matching the event's signature.
action.fire("user_created", project_id, serde_json::json!({ "userId": 42 }))?;The second argument is the project ID this event belongs to, and the third is the payload matching the event's signature.