Skip to main content
Preview
Preview Feature — This feature is currently in preview and under active development. APIs and functionality may change. We recommend testing thoroughly before using in production.

Building Features

This guide covers the complete code-first API for defining features using ductape.features.define().

Feature Definition

Define a feature by passing a configuration object with a handler function:

const feature = await ductape.features.define({
// Target product
tag: 'feature-tag', // Unique identifier
name: 'Feature Name', // Display name
description: 'Description', // Optional
handler: async (ctx) => {
// Your feature logic
return { result: 'data' };
},
});

Definition Options

Basic Properties

await ductape.features.define({
tag: 'order-process',
name: 'Order Processing',
description: 'Handles complete order fulfillment',

// Feature options
options: {
timeout: 300000, // 5 minutes max
rollback_strategy: 'reverse_all', // How to handle failures
},

// Environment configurations
envs: [
{ slug: 'dev', active: true },
{ slug: 'staging', active: true },
{ slug: 'prd', active: true },
],

handler: async (ctx) => {
// ...
},
});

Recording options

Prefer the portable ctx.branch, ctx.when, and ctx.each APIs described below. The older recording options remain available for migration and fixed graph discovery.

OptionPurpose
branchOverridesLegacy compatibility for handlers written before ctx.branch. Do not use it in new Feature code.
recordInputSample input exposed as ctx.sampleInput during recording so loops run and record steps. It never replaces runtime ctx.input references. Use unique step tags per iteration.
recordScenariosLegacy compile-time graph discovery for several sample inputs. Prefer ctx.branch for runtime decisions.

Signals

Define signals that external systems can send to the feature:

await ductape.features.define({
// ...
signals: {
'approve': { input: { approver_id: 'string', comments: 'string' } },
'cancel': { input: { reason: 'string' } },
},

handler: async (ctx) => {
// Wait for approval signal
const approval = await ctx.waitForSignal('approve', { timeout: '24h' });
console.log('Approved by:', approval.approver_id);
},
});

Queries

Define queries to check feature state:

await ductape.features.define({
// ...
queries: {
'getProgress': {
handler: (ctx) => ({
completed_steps: ctx.completed_steps,
current_step: ctx.current_step,
}),
},
},
handler: async (ctx) => {
// ...
},
});

Portable control flow

A Feature handler runs while the Feature is compiled. At execution time Ductape interprets the stored steps graph; it does not call the JavaScript handler again. Native JavaScript control flow therefore must not consume ctx.input or a ctx.step() result.

You want to…Use
Runtime if/elsectx.branch(ctx.when.*, { then, else })
Combine conditionsctx.when.and(...) or ctx.when.or(...)
Expand a fixed sample collectionrecordInput + ctx.each(ctx.sampleInput.items, ...)
Process a runtime-sized collectionA registered portable Function invoked through ctx.functions
Generate runtime strings or identifiersctx.transform.concat, now, uuid, and the string transforms

Runtime branches

const search = await ctx.step('find-customer', () =>
ctx.database.execute({
database: 'payments',
action: 'find-customer',
input: { email: ctx.input.email },
})
);

await ctx.branch(ctx.when.eq(search.data.length, 0), {
then: () => ctx.step('create-customer', () =>
ctx.database.execute({
database: 'payments',
action: 'create-customer',
input: { email: ctx.input.email },
})
),
else: () => ctx.step('reuse-customer', () =>
ctx.database.execute({
database: 'payments',
action: 'touch-customer',
input: { id: search.data[0].id },
})
),
});

Condition builders are eq, ne, gt, gte, lt, lte, truthy, and falsy. Compose them with and and or. Both paths are compiled, and their steps receive inverse runtime conditions automatically.

ctx.branch does not return a selected value. If both paths must feed a common later step, record that later operation in both paths, persist a shared result, or move the find-or-create algorithm into one portable Function.

Deterministic iteration

await ductape.features.define({
tag: 'sync-regions',
name: 'Sync Regions',
recordInput: { regions: ['ng', 'gh'] },
handler: async (ctx) => {
await ctx.each(ctx.sampleInput.regions, async (region, index) => {
await ctx.step(`sync-region-${index}`, () =>
ctx.functions.invoke(syncRegion, 'run', { region })
);
});
},
});

ctx.each may expand a fixed, concrete ctx.sampleInput collection. Do not pre-process that collection with native map, filter, find, indexing, or other JavaScript control flow: those operations run only on the sync host. Use a portable Function for runtime-sized collection logic.

Before any synchronization, run the read-only conformance gate:

ductape features validate --json
ductape features sync

features sync repeats the same AST validation before starting the project-owned registration process. A validation failure means no remote mutation was attempted.

Never generate these patterns
if (result.count === 0) await ctx.step(...);
const customer = found[0] ?? await ctx.step(...);
ctx.input.items.map(item => ctx.step(...));
for (const item of ctx.input.items) await ctx.step(...);
const reference = `pay_${Date.now()}_${Math.random()}`;

Step condition syntax

Conditions are evaluated at runtime by the feature executor. You can combine conditions with && (and) and || (or). Each part can use these operators:

OperatorMeaningExample
==, ===Equal$Step{validate}{valid} == true
!=, !==Not equal$Input{status} != 'draft'
>, >=Greater than (or equal)$Step{score}{value} >= 5
<, <=Less than (or equal)$Input{count} <= 100

Left and right sides can be:

  • Step output: $Step{stepTag}{field} or $Step{stepTag}{nested.field}
  • Feature input: $Input{field} or $Input{nested.field}
  • Literals: true, false, numbers, or quoted strings ('a', "draft")

Examples:

$Step{validate}{available} == true
$Input{type} == 'premium'
$Step{score}{value} >= 80 && $Input{role} != 'guest'
($Input{region} == 'eu') || ($Input{region} == 'uk')

Working with Steps

The ctx.step() Function

Steps are the building blocks of features:

const result = await ctx.step(
'step-tag', // Unique identifier
handler, // Async function
rollback?, // Optional rollback function
options? // Optional step options
);

Basic Step

const user = await ctx.step('create-user', async () => {
return ctx.database.insert({
database: 'users-db',
event: 'create-user',
data: { email: ctx.input.email },
});
});

// Use the result in subsequent steps
console.log('Created user:', user.id);

Step with Rollback

const charge = await ctx.step(
'charge-card',
// Handler
async () => {
return ctx.api.run({
app: 'stripe',
event: 'create-charge',
input: { body: { amount: ctx.input.amount } },
});
},
// Rollback - called if a later step fails
async (result) => {
await ctx.api.run({
app: 'stripe',
event: 'refund',
input: { body: { chargeId: result.id } },
});
}
);

Step Options

await ctx.step(
'send-notification',
async () => {
await ctx.notification.email({ /* ... */ });
},
null, // No rollback
{
allow_fail: true, // Continue on failure
retries: 3, // Retry count
retry_delay: 1000, // Delay between retries (ms)
timeout: 30000, // Step timeout (ms)
critical: false, // Mark as critical for rollback
}
);

Using Ductape Components

ctx.action - API Calls

Call external APIs through connected apps:

// Basic call
const result = await ctx.api.run({
app: 'stripe',
event: 'create-charge',
input: {
body: { amount: 1000, currency: 'usd' },
},
});

// With retries and timeout
const result = await ctx.api.run({
app: 'external-api',
event: 'fetch-data',
input: {
body: { query: ctx.input.query },
headers: { 'X-Request-ID': ctx.feature_id },
},
retries: 3,
timeout: 10000,
});

ctx.database - Database Operations

// Insert
const record = await ctx.database.insert({
database: 'orders-db',
event: 'create-order',
data: {
customer_id: ctx.input.customerId,
total: ctx.input.amount,
items: ctx.input.items,
},
});

// Query
const orders = await ctx.database.query({
database: 'orders-db',
event: 'find-by-customer',
params: { customerId: ctx.input.customerId },
});

// Execute (for custom operations)
const result = await ctx.database.execute({
database: 'analytics-db',
event: 'aggregate-sales',
input: { startDate: ctx.input.from, endDate: ctx.input.to },
});

// Update
await ctx.database.update({
database: 'orders-db',
event: 'update-status',
where: { id: ctx.input.orderId },
data: { status: 'shipped' },
});

// Delete
await ctx.database.delete({
database: 'orders-db',
event: 'remove-order',
where: { id: ctx.input.orderId },
});

ctx.graph - Graph Database

// Create node
const node = await ctx.graph.createNode({
graph: 'social-graph',
labels: ['User'],
properties: { name: ctx.input.name, email: ctx.input.email },
});

// Update node
await ctx.graph.updateNode({
graph: 'social-graph',
id: node.id,
properties: { verified: true },
});

// Create relationship
await ctx.graph.createRelationship({
graph: 'social-graph',
from: ctx.input.userId,
to: ctx.input.friendId,
type: 'FOLLOWS',
properties: { since: new Date().toISOString() },
});

// Query
const connections = await ctx.graph.query({
graph: 'social-graph',
action: 'find-connections',
params: { userId: ctx.input.userId, depth: 2 },
});

// Execute custom action
const result = await ctx.graph.execute({
graph: 'social-graph',
action: 'compute-influence',
input: { userId: ctx.input.userId },
});

// Delete node
await ctx.graph.deleteNode({
graph: 'social-graph',
id: ctx.input.nodeId,
});

// Delete relationship
await ctx.graph.deleteRelationship({
graph: 'social-graph',
id: ctx.input.relationshipId,
});

ctx.notification - Send Notifications

// Email
await ctx.notification.email({
notification: 'transactional',
event: 'order-confirmation',
recipients: [ctx.input.email],
subject: { orderId: ctx.input.orderId },
template: {
orderId: ctx.input.orderId,
items: ctx.input.items,
total: ctx.input.total,
},
});

// SMS
await ctx.notification.sms({
notification: 'alerts',
event: 'verification-code',
phones: [ctx.input.phone],
message: { code: ctx.input.code },
});

// Push notification
await ctx.notification.push({
notification: 'mobile-app',
event: 'order-shipped',
tokens: [ctx.input.deviceToken],
title: { status: 'Shipped' },
body: { trackingNumber: ctx.input.trackingNumber },
data: { orderId: ctx.input.orderId },
});

// Generic send
await ctx.notification.send({
notification: 'multi-channel',
event: 'urgent-alert',
input: {
email: { recipients: [ctx.input.email] },
sms: { phones: [ctx.input.phone] },
push: { tokens: [ctx.input.token] },
},
retries: 3,
});

ctx.storage - File Operations

// Upload file
const uploaded = await ctx.storage.upload({
storage: 'documents',
event: 'upload-invoice',
input: {
buffer: ctx.input.fileData,
fileName: `invoice-${ctx.input.orderId}.pdf`,
mimeType: 'application/pdf',
},
retries: 2,
});

console.log('File URL:', uploaded.url);
console.log('File key:', uploaded.file_key);

// Download file
const file = await ctx.storage.download({
storage: 'templates',
event: 'get-template',
input: { file_key: 'invoice-template.pdf' },
});

console.log('Content:', file.content);
console.log('Size:', file.size);

// Delete file
await ctx.storage.delete({
storage: 'temp-files',
event: 'cleanup',
input: { file_key: ctx.input.tempFileKey },
});

ctx.messaging - Message Broker (Ductape primitive)

// Publish message (event = "broker-tag:topic-tag")
await ctx.messaging.produce({
event: 'order-events:order-created',
message: {
orderId: ctx.input.orderId,
customerId: ctx.input.customerId,
items: ctx.input.items,
timestamp: Date.now(),
},
});

ctx.quota - Rate Limiting

// Check quota before proceeding
const quotaResult = await ctx.quota.execute({
quota: 'api-rate-limit',
input: { key: ctx.input.userId },
timeout: 5000,
});

if (!quotaResult.allowed) {
throw new Error('Rate limit exceeded');
}

ctx.fallback - Failure Handling

// Use fallback for resilient operations
const result = await ctx.fallback.execute({
fallback: 'payment-fallback',
input: {
amount: ctx.input.amount,
method: ctx.input.paymentMethod,
},
timeout: 30000,
});

Control Flow

ctx.sleep() - Pause Execution

// Wait 5 seconds
await ctx.sleep(5000);

// Wait using duration string
await ctx.sleep('5m'); // 5 minutes
await ctx.sleep('1h'); // 1 hour
await ctx.sleep('1d'); // 1 day

ctx.waitForSignal() - Wait for External Event

// Wait for a single signal
const approval = await ctx.waitForSignal('order-approved', {
timeout: '24h',
});

// Wait for any of multiple signals
const result = await ctx.waitForSignal(['approved', 'rejected'], {
timeout: '48h',
});

// Access signal payload
console.log('Approved by:', result.approvedBy);

ctx.checkpoint() - Save Progress

// Save state for recovery
await ctx.checkpoint('payment-complete', {
chargeId: payment.id,
amount: ctx.input.amount,
timestamp: Date.now(),
});

ctx.triggerRollback() - Manual Rollback

// Manually trigger rollback
if (fraudCheck.score > 0.8) {
const result = await ctx.triggerRollback('High fraud risk detected');
console.log('Rolled back steps:', result.rolled_back_steps);
return { success: false, reason: 'fraud_detected' };
}

ctx.feature() - Child Features

// Run another feature as a child
const paymentResult = await ctx.feature(
'child-payment-123', // Child feature ID
'payment-processing', // Feature tag
{ amount: ctx.input.amount }, // Input
{
timeout: '5m',
retries: 2,
parent_close_policy: 'terminate',
}
);

State Management

ctx.setState() and ctx.getState()

// Save state
ctx.setState('retryCount', 0);
ctx.setState('lastError', null);

// Later, retrieve state
const retryCount = ctx.getState<number>('retryCount') || 0;
if (retryCount < 3) {
ctx.setState('retryCount', retryCount + 1);
// Retry logic...
}

// Access all state
console.log('Current state:', ctx.state);

ctx.steps - Access Step Results

// Access results from previous steps
const userResult = ctx.steps['create-user'];
console.log('User ID:', userResult.id);

// Check completed steps
console.log('Completed:', ctx.completed_steps);
console.log('Current:', ctx.current_step);

Data Access

ctx.variable() and ctx.constant()

// Access app variables
const apiVersion = ctx.variable('stripe', 'api_version');

// Access app constants
const defaultCurrency = ctx.constant('stripe', 'default_currency');

ctx.default() - Fallback Values

// Provide default values
const currency = ctx.default(ctx.input.currency, 'usd');
const retries = ctx.default(ctx.input.retries, 3);

ctx.transform - Data Transformations

// String transformations
const reference = ctx.transform.concat(
'pay_',
ctx.transform.now(),
'_',
ctx.transform.uuid(),
);
const upper = ctx.transform.upper(ctx.input.name);
const lower = ctx.transform.lower(ctx.input.email);
const trimmed = ctx.transform.trim(ctx.input.text);
const replaced = ctx.transform.replace(ctx.input.name, ' ', '-');
const prefix = ctx.transform.substring(ctx.input.code, 0, 4);

// JSON operations
const parsed = ctx.transform.parseJson<MyType>(ctx.input.jsonString);
const stringified = ctx.transform.stringify(ctx.input.data);

// Date operations
const now = ctx.transform.now();
const uniqueId = ctx.transform.uuid();
const formatted = ctx.transform.formatDate(ctx.transform.now(), 'yyyy-MM-dd');

// Array/object operations
const length = ctx.transform.length(ctx.input.items);
const size = ctx.transform.size(ctx.input.metadata);
const parts = ctx.transform.split(ctx.input.csv, ',');
const joined = ctx.transform.join(ctx.input.tags, ', ');

now(), uuid(), and transforms that consume operator-backed values are resolved by FeatureExecutor for every execution. They are not evaluated while the Feature is synchronized. Never use Date.now() or Math.random() in a Feature handler to create a runtime value.


Logging

// Log messages at different levels
ctx.log.debug('Processing step', { step: 'create-user' });
ctx.log.info('User created', { userId: user.id });
ctx.log.warn('Retry attempt', { attempt: 2, maxRetries: 3 });
ctx.log.error('Step failed', { error: err.message });

Legacy branchOverrides example

Migration reference only

The following example documents the older branchOverrides mechanism for existing Features. It requires controlFlowMode: 'legacy'. Do not generate new code in this style. New Features should use ctx.branch and ctx.when; the legacy form remains here only to help migrate stored definitions safely.

await ductape.features.define({
tag: 'order-fulfillment',
name: 'Order Fulfillment',
description: 'Complete order processing feature',
controlFlowMode: 'legacy',

options: {
timeout: 300000,
rollback_strategy: 'reverse_all',
},

signals: {
'cancel-order': { input: { reason: 'string' } },
},

// Record the "continue" branch so payment and later steps are in the schema
branchOverrides: { validate: { available: true } },

handler: async (ctx) => {
ctx.log.info('Starting order fulfillment', { orderId: ctx.input.orderId });

// Step 1: Validate order
const validation = await ctx.step('validate', async () => {
return ctx.api.run({
app: 'inventory-api',
event: 'check-availability',
input: { body: { items: ctx.input.items } },
});
});

if (!validation.available) {
return { success: false, reason: 'items_unavailable' };
}

// Step 2: Process payment (with rollback)
const payment = await ctx.step(
'payment',
async () => {
return ctx.api.run({
app: 'stripe',
event: 'create-charge',
input: {
body: {
amount: ctx.input.amount,
customer: ctx.input.customerId,
},
},
retries: 3,
});
},
async (result) => {
await ctx.api.run({
app: 'stripe',
event: 'refund',
input: { body: { chargeId: result.id } },
});
}
);

// Checkpoint after payment
await ctx.checkpoint('payment-complete', { chargeId: payment.id });

// Step 3: Reserve inventory (with rollback)
await ctx.step(
'reserve-inventory',
async () => {
return ctx.api.run({
app: 'inventory-api',
event: 'reserve',
input: { body: { items: ctx.input.items } },
});
},
async () => {
await ctx.api.run({
app: 'inventory-api',
event: 'release',
input: { body: { items: ctx.input.items } },
});
}
);

// Step 4: Create order record
const order = await ctx.step('create-order', async () => {
return ctx.database.insert({
database: 'orders-db',
event: 'create',
data: {
customer_id: ctx.input.customerId,
items: ctx.input.items,
total: ctx.input.amount,
payment_id: payment.id,
status: 'processing',
},
});
});

// Step 5: Send confirmation (non-critical)
await ctx.step(
'send-confirmation',
async () => {
await ctx.notification.email({
notification: 'transactional',
event: 'order-confirmed',
recipients: [ctx.input.email],
template: {
orderId: order.id,
items: ctx.input.items,
total: ctx.input.amount,
},
});
},
null,
{ allow_fail: true }
);

// Step 6: Queue for fulfillment
await ctx.step('queue-fulfillment', async () => {
await ctx.messaging.produce({
event: 'warehouse:new-order',
message: {
orderId: order.id,
items: ctx.input.items,
},
});
});

ctx.log.info('Order fulfilled successfully', { orderId: order.id });

return {
success: true,
orderId: order.id,
chargeId: payment.id,
};
},
});

Next Steps