Skip to content

trestleinc/crane

Repository files navigation

@trestleinc/crane

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.

Installation

npm install @trestleinc/crane
# or
bun add @trestleinc/crane

Setup

1. Add the component

// 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;

2. Create your crane instance

// 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 },
});

3. Create an automation executor (Node.js action)

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!,
		});
	},
});

4. Start executions

// 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 ?? {},
		});
	},
});

Core Concepts

Automations

An automation is a sequence of tiles (automation steps):

flowchart LR
    NAVIGATE --> AUTH --> TYPE --> CLICK --> SCREENSHOT --> EXTRACT
Loading
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

Executions

Track automation runs with:

  • Status (pendingrunningcompleted/failed/cancelled)
  • Duration and timing
  • Extracted outputs
  • Screenshot artifacts

Credential Vault

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

Authorization Pattern

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 });
	},
});

Client API

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,
});

Vault Operations

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,
});

Execution Architecture

Crane uses a getExecutorHandle callback pattern where your host app provides an automation executor that runs in a Node.js action. This design ensures:

  1. Single browser session per automation - All tiles execute in one Stagehand instance
  2. Node.js runtime - Convex components can't use "use node", so execution happens in your app
  3. Workpool-based execution - @convex-dev/workpool handles concurrency control and retry with exponential backoff

How it works

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 }
Loading

Executor result

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;
}

Resource API Reference

automations

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 }

execution

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

credentials

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

vault

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

Error Handling

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.

Type Exports

Shared (@trestleinc/crane/shared)

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, fieldTypeValidator
  • tileValidator, tilePositionValidator, tileConnectionsValidator
  • automationDocValidator, executionDocValidator, credentialDocValidator, vaultDocValidator
  • automationInputValidator, automationUpdateValidator
  • executionInputValidator, executorResultValidator, tileResultValidator
  • credentialInputValidator, credentialUpdateValidator
  • vaultSetupInputValidator, vaultEnableInputValidator
  • listOptionsValidator, 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

Server (@trestleinc/crane/server)

// 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

Client (@trestleinc/crane/client)

// 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

Executor (@trestleinc/crane/executor)

// 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);

Project Structure

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)

Getter / Factory / Namespace Pattern

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 encryption

3. 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 mutation

4. 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';

Technology Stack

  • 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)

License

Apache-2.0

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •