Workflows
Workflows let you group multiple protected effects into a named business process. Each workflow has cases (business entities) and runs (execution attempts). If a run fails partway through, a new run picks up where the last one left off — completed effects return cached results, and only pending effects execute.
Mental Model
Workflow: "refund_flow"
│
├── Case: "order_123"
│ ├── Run: "run_001" (crashed after step 2)
│ │ ├── validate_order ✅ committed
│ │ ├── process_refund ✅ committed
│ │ └── notify_customer ❌ (agent crashed)
│ │
│ └── Run: "run_002" (recovery)
│ ├── validate_order ↩️ cached (skipped)
│ ├── process_refund ↩️ cached (skipped)
│ └── notify_customer ✅ committed (executed)
│
└── Case: "order_456"
└── Run: "run_001"
├── validate_order ✅ committed
├── process_refund ✅ committed
└── notify_customer ✅ committedKey principles:
- Cases are business-scoped — one case per order, customer, or intent
- Runs are execution attempts — retry the same case with a new run
- Effect keys are deterministic —
"${caseId}:${effectName}", same across runs - Completed effects are cached — a re-run skips them instantly
Basic Usage
TypeScript
import { VerityClient } from '@verityinc/sdk';
const verity = new VerityClient({
baseUrl: 'https://api.useverity.io/v1',
apiKey: process.env.VERITY_API_KEY!,
});
async function processRefund(orderId: string) {
// Create a run for this case (order)
const run = verity.workflow('refund_flow').case(orderId).run();
// Step 1: Validate
const order = await run.protect('validate_order', {
act: () => orderService.validate(orderId),
});
// Step 2: Refund (with observe for crash recovery)
const refund = await run.protect('process_refund', {
observe: async () => {
const existing = await stripe.refunds.list({ charge: order.chargeId, limit: 1 });
return existing.data.length > 0 ? existing.data[0] : null;
},
act: () => stripe.refunds.create({ charge: order.chargeId }),
});
// Step 3: Notify
await run.protect('notify_customer', {
act: () => emailService.send({
to: order.customerEmail,
template: 'refund_complete',
data: { refundId: refund.id, amount: order.amount },
}),
});
return { orderId, refundId: refund.id };
}Python
from verity import VerityClient
verity = VerityClient(
base_url="https://api.useverity.io/v1",
api_key=os.environ["VERITY_API_KEY"],
)
async def process_refund(order_id: str):
run = verity.workflow("refund_flow").case(order_id).run()
order = await run.protect("validate_order", act=validate_order)
refund = await run.protect(
"process_refund",
observe=check_existing_refund,
act=execute_refund,
)
await run.protect("notify_customer", act=send_email)
return {"order_id": order_id, "refund_id": refund["id"]}Effect Key Derivation
Within a workflow, the SDK derives effect keys automatically:
effectKey = `${caseId}:${effectName}`
// Examples:
// run.protect("validate_order", ...) → "order_123:validate_order"
// run.protect("process_refund", ...) → "order_123:process_refund"
// run.protect("notify", ..., { keySuffix: "alice@ex.com" }) → "order_123:notify:alice@ex.com"This means the same effect in different runs maps to the same key. If run_001 already committed process_refund, run_002 calling run.protect('process_refund', ...) gets the cached result immediately.
Crash Recovery
When an agent crashes mid-workflow, here's what happens on re-run:
- Completed effects return cached results. The lease request comes back as
cached_completedwith the original result. No re-execution. - Failed effects throw
EffectPreviouslyFailedError. An admin must reset the effect in Explorer before the workflow can continue. - Expired effects trigger
observe(). If the prior agent crashed after acting but before committing, the observe function checks the external system to determine what actually happened. - Pending effects execute normally. Effects that were never attempted proceed with
act()as usual.
protect() calls. Verity handles deduplication and recovery automatically based on each effect's state.Cardinality with keySuffix
When the same logical action applies to multiple entities (e.g., notifying multiple recipients), use keySuffix to create distinct effect keys:
// TypeScript
for (const recipient of recipients) {
await run.protect('notify', {
act: () => emailService.send({ to: recipient }),
}, {
keySuffix: recipient,
// effectKey = "order_123:notify:alice@example.com"
// effectKey = "order_123:notify:bob@example.com"
});
}# Python
for recipient in recipients:
await run.protect(
"notify",
act=lambda r=recipient: email_service.send(to=r),
key_suffix=recipient,
)Cross-Namespace Workflows
A single workflow can span multiple namespaces. Override the namespace per-effect:
const run = verity.workflow('onboarding_flow').case('tenant_abc').run();
// This effect lives in the "billing" namespace
await run.protect('setup_billing', {
act: () => billingService.createAccount(tenantId),
}, { namespace: 'billing' });
// This effect lives in the "provisioning" namespace
await run.protect('provision_infra', {
act: () => cloudProvider.createResources(tenantId),
}, { namespace: 'provisioning' });
// This effect uses the default namespace (workflowName = "onboarding_flow")
await run.protect('send_welcome', {
act: () => emailService.sendWelcome(tenantId),
});Run IDs and Tracing
Every run gets a unique ID (auto-generated or explicit). Run IDs appear in the audit trail and Explorer UI, making it easy to trace which execution attempt produced each result.
// Auto-generated run ID
const run1 = verity.workflow('refund_flow').case('order_123').run();
console.log(run1.runId); // "run_1708444800000_a7f3bc2e"
// Explicit run ID for tracing
const run2 = verity.workflow('refund_flow').case('order_123').run({
runId: 'attempt-3',
});Viewing Workflows in Explorer
The Explorer UI shows workflows as cases on the Cases page:
- Timeline view — cases grouped by date, showing status and progress
- Run tracking — how many runs a case took (e.g., "2 runs" means one recovery)
- Effect details — drill into each effect to see the full audit trail
- Error hints — failed cases show a brief error description inline
Best Practices
Use meaningful case IDs
Case IDs should map to your business entities. Good: order_48392, tenant_abc, invoice_2024_003. Bad: uuid-v4 (hard to find in Explorer).
Use meaningful effect names
Effect names should describe the action: validate_order, process_refund, provision_vm. These appear in the Explorer and help operators understand what happened.
Always add observe() for costly actions
For actions that cost money, create infrastructure, or send notifications — always provide an observe function. It's your safety net for crash recovery.
Keep workflows idempotent-safe from the start
Don't rely on Verity as a crutch for poorly designed workflows. Design each step to be retryable from the start, with Verity as the coordination layer that prevents duplicates.
What's Next?
- Error Handling — understand every error type, especially
CommitUncertainError - Explorer UI — view workflow cases, runs, and audit trails
- Core Concepts — deeper dive into leases, fence tokens, and the observe/act pattern