npm package: @orbflow/sdk
Description: TypeScript SDK for building Orbflow workflow plugins — fluent builder API, gRPC server, no codegen required
Author: The Orbflow Authors
Latest version: 0.1.0

# @orbflow/sdk

TypeScript SDK for building [Orbflow](https://github.com/orbflow-dev/orbflow) workflow plugins.

Build custom action and trigger nodes that appear in the Orbflow visual workflow builder and execute within the distributed engine. No protobuf code generation required.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Deploying to Orbflow](#deploying-to-orbflow)
- [API Reference](#api-reference)
  - [orbflow() — Fluent Builder](#orbflowname-version--fluent-builder-recommended)
  - [Field — Field Builder](#field--field-builder)
  - [serve() — gRPC Server](#serveplugin-options--grpc-server)
  - [getCredential() — Credential Helper](#getcredentialctx-key-defaultvalue--credential-helper)
  - [Object API](#object-api-alternative)
- [Handler Context](#handler-context-executecontext)
- [Return Values](#return-values)
- [Plugin Manifest](#plugin-manifest)
- [Examples](#examples)
- [Architecture](#architecture)
- [Development](#development)
- [License](#license)

## Installation

```bash
npm install @orbflow/sdk
```

**Requirements:** Node.js >= 18.0.0

**Peer dependencies** (installed automatically):
- `@bufbuild/protobuf` ^2.0.0
- `@connectrpc/connect` ^2.0.0
- `@connectrpc/connect-node` ^2.0.0

## Quick Start

Create a file called `main.ts`:

```typescript
import { orbflow, Field, serve } from "@orbflow/sdk";

const plugin = orbflow("my-plugin", "1.0.0")
  .author("Your Name")
  .category("utility")
  .icon("zap")
  .action("greet", {
    inputs: [Field.string("name").required().label("Name")],
    outputs: [Field.string("greeting")],
    run: async ({ input }) => ({
      greeting: `Hello, ${input.name}!`,
    }),
  })
  .build();

serve(plugin);
```

Run it locally:

```bash
npx tsx main.ts
# [orbflow-sdk] Plugin 'my-plugin' v1.0.0 serving on 0.0.0.0:50051 (1 actions)
```

Your plugin is now running a gRPC server. The Orbflow engine will discover it, fetch its node schemas, and make the "Greet" action available in the workflow builder.

## Deploying to Orbflow

There are two ways to connect your plugin to a running Orbflow instance.

### Option 1: Managed Plugin (Recommended)

Place your plugin in the Orbflow plugins directory and let the engine manage its lifecycle.

**1. Create the plugin directory:**

```
plugins/
└── my-plugin/
    ├── main.ts              # Entry point
    ├── package.json         # Dependencies
    └── orbflow-plugin.json  # Plugin manifest
```

**2. Create the manifest** (`orbflow-plugin.json`):

```json
{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "My custom plugin",
  "author": "Your Name",
  "language": "typescript",
  "protocol": {
    "Grpc": { "default_port": 50051 }
  },
  "node_types": ["plugin:greet"]
}
```

**3. Start Orbflow.** The engine will:
- Detect your plugin in the `plugins/` directory
- Run `npm install` automatically (if `package.json` exists)
- Launch your plugin with `npx tsx main.ts`
- Assign a port via `ORBFLOW_PLUGIN_PORT` environment variable
- Poll the health check endpoint until the plugin is ready
- Call `GetSchemas` to register your nodes in the workflow builder

**Reading the assigned port:**

```typescript
const port = parseInt(process.env.ORBFLOW_PLUGIN_PORT ?? "50051", 10);
serve(plugin, { port });
```

### Option 2: Standalone Plugin

Run your plugin independently and tell Orbflow where to find it.

**1. Start your plugin:**

```bash
npx tsx main.ts
# Listening on 0.0.0.0:50051
```

**2. Register in Orbflow config** (`configs/orbflow.yaml`):

```yaml
plugins:
  dir: "./plugins"
  grpc:
    - name: "my-plugin"
      address: "http://localhost:50051"
      timeout_secs: 30
```

**3. Restart Orbflow.** The engine connects to your plugin's gRPC endpoint, calls `GetSchemas`, and registers the nodes.

This approach is useful for:
- Plugins running on a different machine or in a separate container
- Development — run the plugin with hot reload while Orbflow runs separately
- Plugins that need to manage their own lifecycle

### Verifying Deployment

Once deployed, your plugin's nodes appear in the Orbflow workflow builder's node picker. You can verify by:

1. Opening the workflow builder UI at `http://localhost:3000`
2. Clicking the "+" button to open the node picker
3. Searching for your plugin's action name (e.g., "Greet")
4. The node should appear with the icon and category you defined

Or via the API:

```bash
curl http://localhost:8080/api/node-types | jq '.data[] | select(.ref | startswith("plugin:"))'
```

## API Reference

### `orbflow(name, version)` — Fluent Builder (Recommended)

Creates a plugin builder with chainable methods.

```typescript
const plugin = orbflow("my-plugin", "1.0.0")
  .author("Your Name")              // Plugin author
  .category("utility")              // Marketplace category
  .icon("cloud")                    // Lucide icon name
  .description("Does cool things")  // Description
  .action("my-action", { ... })     // Add action node
  .trigger("my-trigger", { ... })   // Add trigger node
  .build();                         // Finalize → PluginDefinition
```

**Naming rules:**
- Action keys are auto-slugified: `"Send Email"` becomes ref `plugin:send-email`
- Display names auto-capitalize: key `"send-email"` becomes name `"Send Email"`
- You can override the display name with the `name` option

### Action/Trigger Options

```typescript
.action("send-email", {
  name: "Send Email",                          // Display name (auto-capitalized if omitted)
  description: "Send an email via SMTP",       // Help text shown in builder
  inputs:     [Field.string("to").required()], // Input fields from upstream nodes
  outputs:    [Field.boolean("sent")],         // Output fields for downstream nodes
  parameters: [Field.string("subject")],       // Static config set in the builder UI
  icon: "mail",                                // Override plugin-level icon
  color: "#FF6D5A",                            // Node color in builder
  run: async (ctx) => {                        // Handler function
    return { sent: true };
  },
})
```

### `Field` — Field Builder

Fluent field definitions with chainable methods.

**Field types:**

| Factory | Type | UI Control |
|---------|------|------------|
| `Field.string(key)` | `string` | Text input |
| `Field.number(key)` | `number` | Number input |
| `Field.boolean(key)` | `boolean` | Toggle switch |
| `Field.object(key)` | `object` | JSON editor |
| `Field.array(key)` | `array` | Array editor |
| `Field.select(key)` | `select` | Dropdown |
| `Field.credential(key)` | `credential` | Credential picker |
| `Field.code(key)` | `code` | Code editor |
| `Field.json(key)` | `json` | JSON editor |
| `Field.textarea(key)` | `textarea` | Multi-line text |
| `Field.password(key)` | `password` | Masked input |

**Chainable methods (all return `this`):**

```typescript
Field.string("name")
  .required()                  // Mark as mandatory
  .label("Full Name")         // Display label (defaults to key)
  .description("Enter name")  // Help text shown on hover
  .default("World")           // Default value
  .enum("a", "b", "c")       // Dropdown options
```

**Credential fields** accept type filtering to restrict which stored credentials are selectable:

```typescript
Field.credential("api_key").types("openai", "anthropic")
```

### `serve(plugin, options?)` — gRPC Server

Starts the gRPC server. Blocks until SIGTERM/SIGINT.

```typescript
serve(plugin);

// With options:
serve(plugin, {
  host: "0.0.0.0",        // Bind address (default: "0.0.0.0")
  port: 50051,             // gRPC port (default: 50051)
  shutdownTimeoutMs: 5000, // Graceful shutdown timeout (default: 5000)
});
```

**Server details:**
- Uses native Node.js `http2` module (no external gRPC runtime)
- 4 MB max request size
- Graceful shutdown on SIGTERM/SIGINT with configurable timeout
- Error messages are sanitized — full errors logged to console, generic "internal error" sent to caller

**When managed by Orbflow**, read the port from the environment:

```typescript
serve(plugin, {
  port: parseInt(process.env.ORBFLOW_PLUGIN_PORT ?? "50051", 10),
});
```

### `getCredential(ctx, key, defaultValue?)` — Credential Helper

Safely extract a resolved credential value from the execution context. The engine injects credential data into `config` or `parameters` depending on where the credential field was declared.

```typescript
import { getCredential } from "@orbflow/sdk";

.action("call-api", {
  parameters: [Field.credential("api_key").types("openai").required()],
  run: async (ctx) => {
    const apiKey = getCredential(ctx, "api_key");
    const baseUrl = getCredential(ctx, "base_url", "https://api.openai.com/v1");
    // Use credentials...
  },
})
```

### Object API (Alternative)

For users who prefer plain objects over the fluent builder:

```typescript
import { createPlugin, defineAction, defineTrigger, serve } from "@orbflow/sdk";

const plugin = createPlugin({
  name: "my-plugin",
  version: "1.0.0",
  actions: [
    defineAction({
      ref: "plugin:greet",
      name: "Greet",
      inputs: [{ key: "name", type: "string", required: true }],
      outputs: [{ key: "greeting", type: "string" }],
      execute: async (ctx) => ({
        greeting: `Hello, ${ctx.input.name}!`,
      }),
    }),
  ],
});

serve(plugin);
```

Use `defineTrigger()` for trigger nodes — same as `defineAction()` but sets `nodeKind: "trigger"`.

## Handler Context (`ExecuteContext`)

Every action/trigger handler receives an `ExecuteContext`:

| Property | Type | Description |
|----------|------|-------------|
| `instanceId` | `string` | Workflow execution instance ID |
| `nodeId` | `string` | This node's ID in the workflow DAG |
| `pluginRef` | `string` | Action ref (e.g., `"plugin:greet"`) |
| `attempt` | `number` | Retry attempt number (0-based) |
| `input` | `Record<string, unknown>` | Data from upstream nodes (mapped via CEL expressions or direct wiring) |
| `parameters` | `Record<string, unknown>` | Static config values set in the workflow builder UI |
| `config` | `Record<string, unknown>` | Resolved credentials and node configuration |
| `capabilities` | `Record<string, unknown>` | Output from capability nodes (e.g., database connections) |

## Return Values

Handlers can return data in three ways:

```typescript
// 1. Plain object (most common) — becomes the node's output data
run: async (ctx) => ({ result: "hello", count: 42 })

// 2. ActionResult — explicit data + error (for controlled error reporting)
run: async (ctx) => ({ data: { result: "hello" }, error: "" })

// 3. void — no output data (for side-effect-only nodes like "send notification")
run: async (ctx) => {
  await sendNotification(ctx.input.message);
}
```

**Error handling:**

```typescript
// Throwing an error marks the node as failed:
run: async (ctx) => {
  throw new Error("API rate limit exceeded");
}

// Or return an explicit error:
run: async (ctx) => ({
  data: { partial: "result" },
  error: "Warning: some items failed",
})
```

## Plugin Manifest

When deploying as a managed plugin, create an `orbflow-plugin.json` in your plugin directory:

```json
{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "Short description of what this plugin does",
  "author": "Your Name",
  "license": "MIT",
  "language": "typescript",
  "protocol": {
    "Grpc": { "default_port": 50051 }
  },
  "node_types": [
    "plugin:action-one",
    "plugin:action-two"
  ],
  "tags": ["utility", "transform"],
  "repository": "https://github.com/your-org/your-plugin",
  "icon": "zap",
  "category": "utility",
  "color": "#7C5CFC"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Plugin name (1-64 chars, alphanumeric + hyphens + underscores) |
| `version` | Yes | Semver version string |
| `language` | Yes | `"typescript"`, `"javascript"`, `"python"`, or custom |
| `protocol` | Yes | `{ "Grpc": { "default_port": 50051 } }` for gRPC plugins |
| `node_types` | Yes | Array of `plugin:*` refs this plugin provides |
| `description` | No | Short description for the marketplace |
| `author` | No | Author name |
| `icon` | No | Lucide icon name for the node in the builder |
| `category` | No | Marketplace category |
| `color` | No | Hex color for the node border |
| `tags` | No | Searchable tags |
| `repository` | No | Source code URL |
| `license` | No | SPDX license identifier |

## Examples

### Weather Forecast Plugin

A complete plugin with inputs, outputs, and parameters:

```typescript
import { orbflow, Field, serve } from "@orbflow/sdk";

const plugin = orbflow("orbflow-weather", "1.0.0")
  .author("Orbflow Examples")
  .category("utility")
  .icon("cloud")
  .description("Get weather forecasts for any city.")
  .action("weather-forecast", {
    name: "Weather Forecast",
    description: "Get the current weather forecast for a city.",
    inputs: [
      Field.string("city").required().label("City Name"),
    ],
    outputs: [
      Field.number("temperature").label("Temperature"),
      Field.string("condition").label("Condition"),
      Field.string("city").label("City"),
    ],
    parameters: [
      Field.select("units")
        .default("fahrenheit")
        .enum("fahrenheit", "celsius")
        .label("Temperature Units"),
    ],
    run: async ({ input, parameters }) => {
      const city = input.city as string;
      const units = (parameters.units as string) ?? "fahrenheit";

      const res = await fetch(
        `https://api.weather.example/v1/forecast?city=${encodeURIComponent(city)}&units=${units}`
      );
      const data = await res.json();

      return {
        temperature: data.temp,
        condition: data.condition,
        city,
      };
    },
  })
  .build();

serve(plugin, {
  port: parseInt(process.env.ORBFLOW_PLUGIN_PORT ?? "50052", 10),
});
```

### Credential-Authenticated API Plugin

Using the credential system to securely access external APIs:

```typescript
import { orbflow, Field, serve, getCredential } from "@orbflow/sdk";

const plugin = orbflow("orbflow-openai", "1.0.0")
  .author("Orbflow Examples")
  .category("ai")
  .icon("brain")
  .action("chat-completion", {
    name: "Chat Completion",
    description: "Send a prompt to OpenAI and get a response.",
    inputs: [
      Field.string("prompt").required().label("Prompt"),
    ],
    outputs: [
      Field.string("response").label("Response"),
      Field.number("tokens_used").label("Tokens Used"),
    ],
    parameters: [
      Field.credential("openai_key").types("openai").required().label("API Key"),
      Field.select("model").default("gpt-4o").enum("gpt-4o", "gpt-4o-mini"),
      Field.number("max_tokens").default(1000),
    ],
    run: async (ctx) => {
      const apiKey = getCredential(ctx, "openai_key");
      const model = (ctx.parameters.model as string) ?? "gpt-4o";
      const maxTokens = (ctx.parameters.max_tokens as number) ?? 1000;

      const res = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          model,
          max_tokens: maxTokens,
          messages: [{ role: "user", content: ctx.input.prompt }],
        }),
      });

      const data = await res.json();
      return {
        response: data.choices[0].message.content,
        tokens_used: data.usage.total_tokens,
      };
    },
  })
  .build();

serve(plugin, {
  port: parseInt(process.env.ORBFLOW_PLUGIN_PORT ?? "50051", 10),
});
```

### Multi-Action Text Utilities Plugin

Multiple actions in a single plugin:

```typescript
import { orbflow, Field, serve } from "@orbflow/sdk";

const plugin = orbflow("orbflow-text-utils", "1.0.0")
  .author("Orbflow Examples")
  .category("utility")
  .icon("type")
  .action("uppercase", {
    inputs: [Field.string("text").required()],
    outputs: [Field.string("result")],
    run: async ({ input }) => ({
      result: (input.text as string).toUpperCase(),
    }),
  })
  .action("word-count", {
    inputs: [Field.string("text").required()],
    outputs: [Field.number("count")],
    run: async ({ input }) => ({
      count: (input.text as string).split(/\s+/).filter(Boolean).length,
    }),
  })
  .action("replace", {
    inputs: [Field.string("text").required()],
    outputs: [Field.string("result")],
    parameters: [
      Field.string("find").required(),
      Field.string("replace_with").default(""),
    ],
    run: async ({ input, parameters }) => ({
      result: (input.text as string).replaceAll(
        parameters.find as string,
        parameters.replace_with as string,
      ),
    }),
  })
  .build();

serve(plugin, {
  port: parseInt(process.env.ORBFLOW_PLUGIN_PORT ?? "50051", 10),
});
```

**Manifest for this plugin** (`orbflow-plugin.json`):

```json
{
  "name": "orbflow-text-utils",
  "version": "1.0.0",
  "language": "typescript",
  "protocol": { "Grpc": { "default_port": 50051 } },
  "node_types": [
    "plugin:uppercase",
    "plugin:word-count",
    "plugin:replace"
  ]
}
```

## Architecture

```mermaid
graph TD
    subgraph Engine["Orbflow Engine"]
        Server["Server\n(HTTP / gRPC)"]
        Worker["Worker\n(task execution)"]
        Loader["Plugin Loader\n(discovery + lifecycle)"]
    end

    Loader -- "gRPC (HTTP/2)" --> YourPlugin["Your Plugin\n(@orbflow/sdk)"]
    Loader -- "gRPC (HTTP/2)" --> OtherPlugin["Other Plugins\n(Python SDK, custom)"]
    Worker --> Loader
    Server --> Worker

    style Engine fill:#1a1a2e,stroke:#7C5CFC,stroke-width:2px,color:#fff
    style YourPlugin fill:#7C5CFC,stroke:#5a3fd4,stroke-width:2px,color:#fff
    style OtherPlugin fill:#2d2d44,stroke:#555,stroke-width:1px,color:#aaa
    style Server fill:#2d2d44,stroke:#7C5CFC,color:#fff
    style Worker fill:#2d2d44,stroke:#7C5CFC,color:#fff
    style Loader fill:#2d2d44,stroke:#7C5CFC,color:#fff
```

**How it works:**

1. **Startup**: Orbflow scans the `plugins/` directory, finds your `orbflow-plugin.json` manifest
2. **Install**: Runs `npm install` if `package.json` exists
3. **Launch**: Starts your plugin with `npx tsx main.ts`, passing `ORBFLOW_PLUGIN_PORT`
4. **Health check**: Polls `HealthCheck` RPC every 500ms until the plugin reports healthy (30s timeout)
5. **Schema discovery**: Calls `GetSchemas` RPC to learn about your plugin's nodes (inputs, outputs, parameters, icons, categories)
6. **Registration**: Registers each `plugin:*` ref as a node type in the engine, available in the builder UI
7. **Execution**: When a workflow runs your node, the engine calls `Execute` RPC with input data, your handler runs, and the result flows to downstream nodes
8. **Shutdown**: On SIGTERM, the engine gracefully stops all managed plugin processes

**gRPC service** (`orbflow.plugin.v1.OrbflowPlugin`):

| RPC | Description |
|-----|-------------|
| `Execute(ExecuteRequest) → ExecuteResponse` | Run an action with input data |
| `GetSchemas(GetSchemasRequest) → GetSchemasResponse` | Return all node schemas |
| `HealthCheck(HealthCheckRequest) → HealthCheckResponse` | Report health and version |

**Security**: The engine strips sensitive environment variables (DATABASE_URL, API tokens, encryption keys) before launching plugin processes. Plugins only receive `ORBFLOW_PLUGIN_PORT` and non-sensitive env vars.

## Development

### Setup

```bash
git clone https://github.com/orbflow-dev/orbflow-typescript.git
cd orbflow-typescript
npm install
```

### Commands

```bash
npm test             # Run tests (vitest)
npm run build        # Compile TypeScript → dist/
npm run lint         # Type-check without emitting
npm run test:watch   # Watch mode
```

### Project Structure

```
src/
├── index.ts         # Public API exports
├── functional.ts    # orbflow() fluent builder + createPlugin/defineAction
├── fields.ts        # Field builder (Field.string, Field.number, etc.)
├── types.ts         # Core type definitions (FieldDef, ExecuteContext, etc.)
├── server.ts        # gRPC server (native http2, no protoc needed)
├── proto-codec.ts   # Hand-rolled protobuf encoder/decoder
├── schema.ts        # Node schema building for GetSchemas RPC
└── convert.ts       # Proto ↔ SDK type conversion helpers

tests/               # Vitest tests
examples/
└── weather/         # Complete example plugin
```

### Running the Example

```bash
npx tsx examples/weather/index.ts
# [orbflow-sdk] Plugin 'orbflow-weather' v1.0.0 serving on 0.0.0.0:50052 (1 actions)
```

## License

[Apache-2.0](LICENSE)