Skip to main content

Developer Integration Guide: Custom Modules with Advanced Configuration

This guide shows how to add your own module to ValkyrAI with the new schema-based configuration system.


Example: Building a Stripe Payment Module

Step 1: Define the Configuration Schema

File: com/valkyrlabs/workflow/config/StripeModuleSchemaProvider.java

package com.valkyrlabs.workflow.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

@Component
public class StripeModuleSchemaProvider {
@Autowired
private ModuleConfigSchemaRegistry registry;

@PostConstruct
public void registerStripeSchema() {
ModuleConfigSchema schema = new ModuleConfigSchema(
"STRIPE",
"Stripe Payment Module",
"Process payments via Stripe with custom amounts and metadata"
);

schema.addFields(
// Account Configuration Section
new ModuleConfigSchema.ConfigField("integrationAccountId", "Stripe Account",
ModuleConfigSchema.FieldType.LOOKUP)
.description("Select your Stripe account")
.required(true)
.section("Account")
.order(1)
.apiLookup(new ModuleConfigSchema.ApiLookup(
"/api/v1/integrationaccounts", "id", "accountName")
.filterParam("type", "STRIPE")
.searchable(true)),

// Payment Details Section
new ModuleConfigSchema.ConfigField("amount", "Payment Amount",
ModuleConfigSchema.FieldType.NUMBER)
.description("Amount in cents (e.g., 1000 = $10.00)")
.required(true)
.section("Payment Details")
.order(2),

new ModuleConfigSchema.ConfigField("currency", "Currency",
ModuleConfigSchema.FieldType.SELECT)
.description("Payment currency")
.defaultValue("usd")
.required(true)
.section("Payment Details")
.order(3)
.options(Map.of(
"usd", "USD ($)",
"eur", "EUR (€)",
"gbp", "GBP (£)",
"jpy", "JPY (¥)"
)),

new ModuleConfigSchema.ConfigField("description", "Payment Description",
ModuleConfigSchema.FieldType.TEXT)
.description("Payment description for Stripe dashboard")
.placeholder("Subscription - {{planName}}")
.required(false)
.section("Payment Details")
.order(4),

// Customer Section
new ModuleConfigSchema.ConfigField("customerDataPath", "Customer Data Path",
ModuleConfigSchema.FieldType.TEXT)
.description("Dot-notation path to customer object in workflow state")
.placeholder("customer")
.required(true)
.section("Customer")
.order(5)
.addValidation("required"),

new ModuleConfigSchema.ConfigField("idempotencyKey", "Idempotency Key",
ModuleConfigSchema.FieldType.TEXT)
.description("Unique key for request deduplication (optional)")
.placeholder("{{orderId}}-{{timestamp}}")
.required(false)
.section("Customer")
.order(6),

// Advanced Options
new ModuleConfigSchema.ConfigField("retryPolicy", "Retry on Failure",
ModuleConfigSchema.FieldType.SELECT)
.description("Automatic retry strategy")
.defaultValue("exponential")
.section("Advanced")
.order(7)
.options(Map.of(
"none", "No Retry",
"exponential", "Exponential Backoff",
"linear", "Linear Retry"
)),

new ModuleConfigSchema.ConfigField("webhookUrl", "Webhook URL",
ModuleConfigSchema.FieldType.TEXT)
.description("Optional: URL to receive Stripe webhooks")
.placeholder("https://yourapp.com/webhooks/stripe")
.required(false)
.section("Advanced")
.order(8)
.addValidation("url"),

new ModuleConfigSchema.ConfigField("metadata", "Custom Metadata",
ModuleConfigSchema.FieldType.JSON)
.description("Custom data to store with the charge")
.defaultValue("{}")
.section("Advanced")
.order(9)
);

registry.registerSchema("STRIPE", schema);
}
}

Step 2: Implement the Module

File: com/valkyrlabs/workflow/modules/StripePaymentModule.java

package com.valkyrlabs.workflow.modules;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.valkyrlabs.model.ExecModule;
import com.valkyrlabs.workflow.WorkflowMonitoring;
import com.valkyrlabs.workflow.service.ExecModuleConfigService;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import com.stripe.param.ChargeCreateParams;

@Component("stripePaymentModule")
@VModuleAware
public class StripePaymentModule extends BaseMapIOModule {
private static final Logger log = LoggerFactory.getLogger(StripePaymentModule.class);
private static final long serialVersionUID = 1L;

@Autowired
private ExecModuleConfigService configService;

@Autowired
private IntegrationAccountService integrationAccountService;

@Override
@WorkflowMonitoring
public void execute() {
try {
// Parse configuration using type-safe service
long amount = configService.getIntConfig(execModule, "amount", 0);
String currency = configService.getStringConfig(execModule, "currency", "usd");
String description = configService.getStringConfig(execModule, "description", "");
String customerDataPath = configService.getStringConfig(execModule, "customerDataPath", "");
String integrationAccountId = configService.getStringConfig(execModule, "integrationAccountId", "");

if (amount <= 0) {
outputMap.put("status", "error");
outputMap.put("error", "Amount must be greater than 0");
return;
}

// Get Stripe API key from integration account
String stripeApiKey = integrationAccountService.getApiKey(integrationAccountId);
Stripe.apiKey = stripeApiKey;

// Get customer data from workflow state
Object customerObj = resolveObjectPath(customerDataPath);
if (customerObj == null) {
outputMap.put("status", "error");
outputMap.put("error", "Customer data not found at: " + customerDataPath);
return;
}

String customerId = getCustomerProperty(customerObj, "stripeCustomerId");
String tokenId = getCustomerProperty(customerObj, "stripeTokenId");

if (customerId == null && tokenId == null) {
outputMap.put("status", "error");
outputMap.put("error", "Customer must have stripeCustomerId or stripeTokenId");
return;
}

// Create charge
ChargeCreateParams.Builder chargeBuilder = ChargeCreateParams.builder()
.setAmount(amount)
.setCurrency(currency)
.setDescription(description);

if (customerId != null) {
chargeBuilder.setCustomer(customerId);
} else {
chargeBuilder.setSource(tokenId);
}

// Add custom metadata
var metadata = configService.getObjectConfig(execModule, "metadata");
if (!metadata.isEmpty()) {
chargeBuilder.putAllMetadata(metadata);
}

Charge charge = Charge.create(chargeBuilder.build());

// Success response
outputMap.put("status", "success");
outputMap.put("chargeId", charge.getId());
outputMap.put("amount", charge.getAmount());
outputMap.put("currency", charge.getCurrency());
outputMap.put("paid", charge.getPaid());
outputMap.put("receiptEmail", charge.getReceiptEmail());

log.info("Stripe charge created: {} for ${}", charge.getId(), amount / 100.0);

} catch (Exception e) {
log.error("Stripe payment failed: {}", e.getMessage(), e);
outputMap.put("status", "error");
outputMap.put("error", e.getMessage());
}
}

private Object resolveObjectPath(String path) {
if (inputMap == null) {
return null;
}

String[] parts = path.split("\\.");
Object current = inputMap;

for (String part : parts) {
if (current instanceof java.util.Map) {
@SuppressWarnings("unchecked")
java.util.Map<String, Object> map = (java.util.Map<String, Object>) current;
current = map.get(part);
} else {
return null;
}
}

return current;
}

private String getCustomerProperty(Object customer, String property) {
if (customer instanceof java.util.Map) {
@SuppressWarnings("unchecked")
java.util.Map<String, Object> map = (java.util.Map<String, Object>) customer;
Object value = map.get(property);
return value != null ? String.valueOf(value) : null;
}
return null;
}
}

Step 3: Use in Workflow

The UI is automatically generated from the schema!

In the Workflow Designer:

  1. Add Stripe module to workflow
  2. AdvancedModuleDesigner automatically renders:
    • Stripe account selector (dropdown filtered to STRIPE type)
    • Amount field (number input)
    • Currency selector (dropdown)
    • Customer data path (text field)
    • Retry policy selector
    • Metadata JSON editor
    • Validation on all required fields

Workflow JSON:

{
"name": "Process Payment",
"execModules": [
{
"name": "Stripe Charge",
"moduleType": "STRIPE",
"moduleData": {
"integrationAccountId": "stripe-acct-123",
"amount": 9999,
"currency": "usd",
"description": "Payment for order {{orderId}}",
"customerDataPath": "customer",
"retryPolicy": "exponential",
"metadata": {
"orderId": "{{orderId}}",
"userId": "{{userId}}"
}
}
}
]
}

Adding to Existing Module

If you have an existing module, just add a schema provider:

@Component
public class MyModuleSchemaSetup {
@PostConstruct
public void setup(ModuleConfigSchemaRegistry registry) {
ModuleConfigSchema schema = new ModuleConfigSchema(
"MY_MODULE", "My Module", "Does something"
);

schema.addFields(
new ConfigField("field1", "Field Label", FieldType.TEXT)
.required(true)
);

registry.registerSchema("MY_MODULE", schema);
}
}

That's it! The UI will automatically render.


Testing Your Module

Unit Test

@SpringBootTest
class StripePaymentModuleTests {
@Autowired
private StripePaymentModule module;

@MockBean
private IntegrationAccountService integrationAccountService;

@MockBean
private ExecModuleConfigService configService;

@Test
void testSuccessfulCharge() {
// Setup
ExecModule execModule = new ExecModule();
execModule.setModuleData("{\"amount\": 1000, \"currency\": \"usd\"}");

when(configService.getIntConfig(execModule, "amount", 0))
.thenReturn(1000);

module.setExecModule(execModule);
module.setInputMap(Map.of("customer", Map.of("stripeCustomerId", "cus_123")));

// Execute
module.execute();

// Verify
assertThat(module.getOutputMap().get("status")).isEqualTo("success");
assertThat(module.getOutputMap()).containsKey("chargeId");
}
}

Schema Validation Test

@Test
void testSchemaHasRequiredFields() {
ModuleConfigSchemaRegistry registry = new ModuleConfigSchemaRegistry();
var schema = registry.getSchema("STRIPE");

assertThat(schema).isPresent();
assertThat(schema.get().getFields())
.extracting(ConfigField::getName)
.contains("integrationAccountId", "amount", "currency");
}

Checklist for New Modules

  • Create schema provider component
  • Register all required fields in schema
  • Add appropriate field types (LOOKUP for API calls, SELECT for enums, etc.)
  • Implement module extending BaseMapIOModule
  • Use ExecModuleConfigService for config access
  • Write unit tests
  • Test UI rendering in AdvancedModuleDesigner
  • Document configuration options
  • Add to relevant workflow templates

Best Practices

  1. Use ConfigService for Type Safety

    // ✅ Good
    String value = configService.getStringConfig(module, "field", "default");

    // ❌ Avoid
    Map<String, Object> config = objectMapper.readValue(module.getModuleData(), Map.class);
  2. Validate Configuration

    ValidationResult result = configService.validateConfig(module);
    if (!result.isValid()) {
    // Handle validation errors
    }
  3. Use Appropriate Field Types

    // For API-powered selectors
    .apiLookup(new ApiLookup("/api/endpoint", "id", "name"))

    // For static enums
    .options(Map.of("a", "Option A", "b", "Option B"))
  4. Add Helpful Descriptions

    new ConfigField("field", "Label", FieldType.TEXT)
    .description("Help text that explains what this field does")
    .placeholder("Example value")
  5. Use Sections for Organization

    .section("Authentication")
    .section("Advanced")

Happy module building! 🎉