Browser automation engine with integrated credential vault for Convex.
Crane executes automations—sequences of automation steps—against web portals that lack APIs. It stores automation definitions, manages encrypted credentials, and tracks execution history.
npm install @trestleinc/crane
# or
bun add @trestleinc/crane// convex/convex.config.ts
import crane from '@trestleinc/crane/convex.config';
import { defineApp } from 'convex/server';
const app = defineApp();
app.use(crane);
export default app;// convex/crane.ts
import { crane } from '@trestleinc/crane/server';
import { createFunctionHandle } from 'convex/server';
import { components, internal } from './_generated/api';
// Minimal configuration - hooks are optional
export const c = crane.create(components.crane, {
// Callback pattern avoids circular type inference
getExecutorHandle: async () => {
return createFunctionHandle(internal.automationExecutor.run);
},
// Workpool configuration
concurrency: { maxParallelism: 25 },
retry: { enabled: true, initialBackoffMs: 1000, base: 2, maxAttempts: 3 },
});Crane's workpool calls your automation executor with all tiles. Your executor runs them in a single browser session:
// convex/automationExecutor.ts
'use node';
import { internalAction } from './_generated/server';
import {
executeAutomation,
executorArgsValidator,
executorResultValidator,
} from '@trestleinc/crane/executor';
export const run = internalAction({
args: executorArgsValidator,
returns: executorResultValidator,
handler: async (ctx, args) => {
return executeAutomation(args, {
browserbaseApiKey: process.env.BROWSERBASE_API_KEY!,
browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!,
stagehandModelName: process.env.STAGEHAND_MODEL_NAME!,
stagehandModelApiKey: process.env.STAGEHAND_MODEL_API_KEY!,
});
},
});// convex/crane.ts
import { mutation } from './_generated/server';
import { c } from './crane';
export const start = mutation({
args: {
automationId: v.string(),
variables: v.optional(v.record(v.string(), v.any())),
},
handler: async (ctx, args) => {
// Returns { executionId } - handle resolved via getExecutorHandle callback
return await c.execution.start(ctx, {
automationId: args.automationId,
variables: args.variables ?? {},
});
},
});An automation is a sequence of tiles (automation steps):
flowchart LR
NAVIGATE --> AUTH --> TYPE --> CLICK --> SCREENSHOT --> EXTRACT
| Tile | Purpose |
|---|---|
NAVIGATE |
Go to URL (supports {{variable}} interpolation) |
AUTH |
Login using stored credentials (domain-based lookup) |
TYPE |
Type into a field (from variable or credential) |
CLICK |
Click an element |
EXTRACT |
Extract structured data from page |
SCREENSHOT |
Capture screenshot artifact |
WAIT |
Wait for specified duration |
SELECT |
Select dropdown option |
FORM |
Fill multiple form fields at once |
Track automation runs with:
- Status (
pending→running→completed/failed/cancelled) - Duration and timing
- Extracted outputs
- Screenshot artifacts
Zero-knowledge credential storage:
- Credentials encrypted client-side with AES-256-GCM
- Server never sees plaintext passwords
- Domain-based lookup during AUTH tiles
- WorkOS M2M authentication for automated execution
Important: Component hooks receive generic context types that may not be compatible with your host app's schema. Handle authorization in wrapper functions where you have properly typed context:
// convex/credentials.ts - Authorization at wrapper level
import { query, mutation } from './_generated/server';
import { components } from './_generated/api';
import { verifyOrgAccess } from './permissions';
export const credentialGet = query({
args: { id: v.string() },
handler: async (ctx, { id }) => {
const credential = await ctx.runQuery(components.crane.public.credentialGet, { id });
if (credential) {
// Verify with properly typed context from your schema
await verifyOrgAccess(ctx, credential.organizationId);
}
return credential;
},
});
export const credentialUpdate = mutation({
args: { id: v.string(), name: v.optional(v.string()) },
handler: async (ctx, { id, ...updates }) => {
const credential = await ctx.runQuery(components.crane.public.credentialGet, { id });
if (!credential) throw new Error('Credential not found');
await verifyOrgAccess(ctx, credential.organizationId);
return ctx.runMutation(components.crane.public.credentialUpdate, { id, ...updates });
},
});Build automations with a fluent API:
import { automation, vault } from '@trestleinc/crane/client';
// Create an automation
const submitIntake = automation
.create('submit-intake')
.describe('Submit beneficiary intake form')
.input('portalUrl', 'string', true)
.input('firstName', 'string', true)
.input('lastName', 'string', true)
.navigate('{{portalUrl}}')
.auth()
.type('first name field', { variable: 'firstName' })
.type('last name field', { variable: 'lastName' })
.click('submit button')
.screenshot()
.extract('confirmation number', 'confirmationNumber')
.tag('intake', 'beneficiary')
.build();
// Save to database
await ctx.runMutation(c.automations.create, {
organizationId: org.id,
...submitIntake,
});import { vault } from '@trestleinc/crane/client';
// Setup vault for organization
const setupData = await vault.setup('master-password');
await ctx.runMutation(c.vault.setup, {
organizationId: org.id,
...setupData,
});
// Unlock vault
const vaultData = await ctx.runQuery(c.vault.get, { organizationId: org.id });
const vaultKey = await vault.unlock('master-password', vaultData);
// Encrypt and save credential
const encrypted = await vault.credential.encrypt(vaultKey, {
username: 'user@example.com',
password: 'secret123',
});
await ctx.runMutation(c.credentials.create, {
organizationId: org.id,
name: 'Portal Login',
domain: 'portal.example.com',
encryptedPayload: encrypted.ciphertext,
payloadIv: encrypted.iv,
});Crane uses a getExecutorHandle callback pattern where your host app provides an automation executor that runs in a Node.js action. This design ensures:
- Single browser session per automation - All tiles execute in one Stagehand instance
- Node.js runtime - Convex components can't use
"use node", so execution happens in your app - Workpool-based execution -
@convex-dev/workpoolhandles concurrency control and retry with exponential backoff
sequenceDiagram
participant Host as Host App
participant Crane as Crane Component
participant Workpool as @convex-dev/workpool
participant Executor as Automation Executor
participant Browser as Stagehand/Browser
Host->>Crane: c.execution.start(automationId, variables)
activate Crane
Crane->>Crane: getExecutorHandle() callback
Crane->>Crane: Load automation
Crane->>Crane: Sort tiles by connection order
Crane->>Workpool: Queue execution job
deactivate Crane
activate Workpool
Workpool->>Executor: callHostAutomationExecutor(tiles, variables)
deactivate Workpool
activate Executor
Executor->>Browser: new Stagehand()
Note over Browser: ONE instance for all tiles
loop For each tile
Executor->>Browser: Execute tile with shared page
Browser-->>Executor: Tile result
end
Executor->>Browser: stagehand.close()
Executor-->>Crane: ExecutorResult
deactivate Executor
Crane-->>Host: { executionId }
interface ExecutorResult {
success: boolean;
duration?: number;
tileResults: Array<{
tileId: string;
status: 'completed' | 'failed';
result?: unknown;
error?: string;
duration?: number;
screenshotStorageId?: Id<'_storage'>;
}>;
outputs?: Record<string, unknown>;
error?: string;
}| Method | Args | Returns |
|---|---|---|
get |
{ id } |
Automation or null |
list |
{ organizationId, limit? } |
Automation[] |
create |
{ organizationId, name, description?, tiles, metadata? } |
{ id } |
update |
{ id, name?, description?, tiles?, metadata? } |
{ id } |
remove |
{ id } |
{ removed: boolean } |
| Method | Args | Returns |
|---|---|---|
get |
{ id } |
Execution or null |
list |
{ organizationId, automationId?, status?, limit? } |
Execution[] |
start |
{ automationId, variables } |
{ executionId } |
complete |
{ id, result } |
{ completed: boolean } |
cancel |
{ id, reason? } |
{ cancelled: boolean } |
progress |
{ id } |
Progress info |
| Method | Args | Returns |
|---|---|---|
get |
{ id } |
Credential or null |
list |
{ organizationId, limit? } |
Credential[] |
create |
{ organizationId, name, domain, encryptedPayload, payloadIv } |
{ id } |
update |
{ id, name?, domain?, encryptedPayload?, payloadIv? } |
{ id } |
remove |
{ id } |
{ removed: boolean } |
resolve |
{ organizationId, domain } |
Credential or null |
| Method | Args | Returns |
|---|---|---|
get |
{ organizationId } |
Vault or null |
setup |
{ organizationId, salt, iterations, encryptedVaultKey, vaultKeyIv, verificationHash } |
null |
unlock |
{ organizationId } |
Vault or null |
enable |
{ organizationId, encryptedMachineKey, machineKeyIv, workosM2MClientId? } |
null |
context |
{ organizationId, browserbaseContextId } |
null |
Crane provides custom error types for structured error handling:
import {
CraneError, // Base class for all Crane errors
NotFoundError, // Resource not found (includes resource type + ID)
ValidationError, // Validation failed (includes message)
AuthorizationError, // Authorization denied (includes message)
} from '@trestleinc/crane/server';
// Handling errors
try {
await ctx.runMutation(c.automations.remove, { id });
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`Resource ${error.code}: ${error.message}`);
} else if (error instanceof AuthorizationError) {
console.log(`Auth failed: ${error.message}`);
}
}All errors extend CraneError which includes a code property for programmatic handling.
All validators and types are consolidated in a single source of truth. Types are derived from validators using Infer<typeof validator>.
import {
// Validators
tileTypeValidator,
executionStatusValidator,
automationDocValidator,
executionDocValidator,
credentialDocValidator,
vaultDocValidator,
tileValidator,
metadataValidator,
// ... and more
// Types (derived from validators)
type Automation,
type Execution,
type Credential,
type Vault,
type Tile,
type TileType,
type ExecutionStatus,
type Metadata,
type TileResult,
type ExecutorResult,
// ... and more
// Branded IDs for type safety
type AutomationId,
type ExecutionId,
type CredentialId,
type OrganizationId,
// ID factory
createId,
} from '@trestleinc/crane/shared';
// Usage
const automationId = createId.automation('auto_123');
const executionId = createId.execution('exec_789');
const orgId = createId.organization('org_456');
// Unified Logger (import from $/shared/logger)
import { getLogger } from '$/shared/logger';
const logger = getLogger(['crane', 'execution']);
logger.info('Execution started', { executionId });Available Validators:
tileTypeValidator,executionStatusValidator,tileStatusValidator,fieldTypeValidatortileValidator,tilePositionValidator,tileConnectionsValidatorautomationDocValidator,executionDocValidator,credentialDocValidator,vaultDocValidatorautomationInputValidator,automationUpdateValidatorexecutionInputValidator,executorResultValidator,tileResultValidatorcredentialInputValidator,credentialUpdateValidatorvaultSetupInputValidator,vaultEnableInputValidatorlistOptionsValidator,automationListOptionsValidator,executionListOptionsValidator,credentialListOptionsValidator
Available Types:
- Document types:
Automation,Execution,Credential,Vault - Core types:
Tile,TileType,ExecutionStatus,TileStatus,Metadata,TileResult,ExecutorResult,Artifact - Input types:
AutomationInput,AutomationUpdate,ExecutionInput,CredentialInput,CredentialUpdate,VaultSetupInput,VaultEnableInput - Branded IDs:
AutomationId,ExecutionId,CredentialId,OrganizationId
// Factory
crane(component); // Create crane instance with options
// Component API type (type-safe, no more `any`)
CraneComponentApi; // Shape of Crane component's public API
// Options types
CraneOptions; // Full configuration object (includes getExecutorHandle, concurrency, retry)
// Error types
CraneError; // Base error class with code property
NotFoundError; // Resource not found
ValidationError; // Validation failed
AuthorizationError; // Authorization denied
// Execution types
ExecutionStartOptions; // Options for c.execution.start()
ExecutionStartResult; // { executionId } - result of starting execution
ExecutorResult; // Result of automation execution
TileResult; // Result of individual tile execution
// Utilities
sortTiles; // Sort tiles by connection order
interpolate; // Template string interpolation// Automation builder (fluent API)
automation.create(name).navigate(url).auth().type(instruction, opts).click(instruction).build();
// Vault operations
vault.setup(password);
vault.unlock(password, vaultData);
vault.credential.encrypt(key, fields);
vault.credential.decrypt(key, encrypted);
// Error types (Effect-based)
NetworkError; // Network failures with retry info
AuthorizationError; // Auth failures with org context
NotFoundError; // Entity not found with type
ValidationError; // Validation errors with field info
VaultUnlockError; // Vault unlock failures
CredentialNotFoundError; // Credential lookup failures
NonRetriableError; // Errors that should not be retried// Node.js-only exports (import in files with "use node")
import {
executeAutomation,
executorArgsValidator,
executorResultValidator,
} from '@trestleinc/crane/executor';
// Execute all tiles in a single browser session
executeAutomation(args, config);src/
├── shared/
│ └── index.ts # All validators, types, branded IDs, utilities
├── client/
│ └── index.ts # Automation builder, vault operations, error types
├── server/
│ ├── index.ts # Factory function, error types, component API
│ └── resources/ # Resource implementations (automations, executions, etc.)
├── component/
│ └── ... # Convex component internals
└── executor/
└── index.ts # Node.js automation executor (Stagehand integration)
Crane follows a consistent API design pattern across all entry points:
1. Factory Pattern - Create configured instances:
import { crane } from '@trestleinc/crane/server';
import { createFunctionHandle } from 'convex/server';
// Factory creates a configured crane instance
const c = crane.create(components.crane, {
getExecutorHandle: async () => createFunctionHandle(internal.executor.run),
concurrency: { maxParallelism: 25 },
});2. Namespace Pattern - Organized resource access:
// Resources are organized under namespaces
c.automations.get; // Get an automation
c.automations.list; // List automations
c.automations.create; // Create an automation
c.execution.start; // Start an execution (returns { executionId })
c.vault.setup; // Setup vault encryption3. Getter Pattern - Direct property access for resource methods:
// Each method returns a Convex function reference
export const get = c.automations.get; // Re-export as Convex query
export const list = c.automations.list; // Re-export as Convex query
export const create = c.automations.create; // Re-export as Convex mutation4. Single Entry Point - All exports consolidated per layer:
// Server - everything from one import
import { crane, CraneError, NotFoundError, sortTiles } from '@trestleinc/crane/server';
// Client - everything from one import
import { automation, vault, NetworkError } from '@trestleinc/crane/client';
// Shared - all validators and types from one import
import { Automation, Execution, automationDocValidator, createId } from '@trestleinc/crane/shared';- Convex - Database and serverless functions
- @convex-dev/workpool - Concurrency control and retry with exponential backoff
- Stagehand - AI-powered browser automation
- Browserbase - Cloud browser infrastructure
- WorkOS - M2M authentication for automated execution
- Web Crypto API - Client-side credential encryption (AES-256-GCM)
- LogTape - Unified structured logging (import from
$/shared/logger)
Apache-2.0