Scrydon
ExtensionsAuthoring

Getting Started

Build your first custom extension in 5 minutes

This guide walks you through creating a minimal "Hello World" extension that logs a message and passes data through. By the end, you'll have a .archive.tar.gz file ready to ship inside an extension.

Prerequisites

  • Bun v1.1+ (or Node.js 20+)
  • TypeScript 5.7+

Project Setup

Fastest path: scaffold the whole project with the CLI instead of the manual steps below —

bunx @scrydon/sdk-authoring extension init my-vendor --outDir ./extensions/authoring
cd extensions/my-vendor

The interactive prompts pick the auth type (OAuth, API key, or none), the capabilities to scaffold, and a brand color, then tailor the generated src/index.ts to your selections. Pass --yes to skip prompts and accept defaults (auth = none, capabilities = blocks & tools) — useful for CI. Either way you get package.json, tsconfig.json, a defineExtension() skeleton, and a sample live test under src/__tests__/.

mkdir my-extension && cd my-extension
bun init -y

Update package.json:

{
  "name": "my-extension",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "src/index.ts"
}
bun add -d @scrydon/sdk-authoring zod

Write the Extension

Create src/index.ts:

import {
  defineExtension,
  defineToolkit,
  defineTool,
  defineBlock,
} from "@scrydon/sdk-authoring/extensions/authoring/define";
import type { ToolResponse } from "@scrydon/sdk-authoring/extensions/authoring/context";
import { z } from "zod";

// 1. Define a tool — the runtime logic
const sayHelloTool = defineTool({
  id: "say-hello",
  name: "Say Hello",
  version: "1.0.0",
  description: "Logs a greeting and passes the message through.",
  input: z.object({
    message: z.string().describe("The message to pass through"),
  }),
  output: z.object({
    message: z.string().describe("The original message, unchanged"),
  }),
  params: {
    message: {
      type: "string",
      required: true,
      visibility: "user-or-llm",
      description: "Message to pass through the block",
    },
  },
  async execute(input, ctx): Promise<ToolResponse<{ message: string }>> {
    ctx.logger.info(`Hello World! Received: "${input.message}"`);
    return {
      success: true,
      output: { message: input.message },
    };
  },
});

// 2. Define a block — the workflow editor UI
const helloBlock = defineBlock({
  type: "hello_world",
  name: "Hello World",
  description: "Logs a greeting and passes input to output.",
  category: "extension",
  bgColor: "#22C55E",
  authMode: "none",
  subBlocks: [
    {
      id: "message",
      title: "Message",
      type: "short-input",
      placeholder: "Enter a message…",
      required: true,
    },
  ],
  tools: {
    access: ["say-hello"],
  },
  inputs: {
    message: { type: "string", description: "Incoming message" },
  },
  outputs: {
    message: { type: "string", description: "Outgoing message" },
  },
});

// 3. Define a toolkit — the grouping unit
const helloToolkit = defineToolkit({
  id: "hello-world",
  name: "Hello World",
  description: "A minimal demo extension.",
  logo: "./assets/icon.svg",
  tools: [sayHelloTool],
  block: helloBlock,
  triggers: [],
});

// 4. Export the extension — the top-level container
export default defineExtension({
  id: "hello-world",
  name: "Hello World",
  version: "1.0.0",
  description: "A minimal example extension.",
  color: "#22C55E",
  logo: "./assets/icon.svg",
  categories: ["extension"],
  connectivity: "local",
  auth: {
    credentials: { none: { type: "none" } },
    default: "none",
  },
  provides: { toolkits: [helloToolkit] },
});

The entry point must have a default export that is a defineExtension() result. The build CLI reads this to extract the manifest.

Tool ID shorthand — You can use short tool IDs like "say-hello" instead of the fully qualified "hello-world:hello-world:say-hello". The SDK auto-qualifies short IDs by prepending {extensionId}:{providedId}: during defineExtension(). All first-party extensions use the short form.

Understanding the Layers

The code above has four layers, each with a specific role:

LayerWhat it definesKey fields
defineTool()Runtime logicinput (Zod), output (Zod), execute() function
defineBlock()Workflow editor UIsubBlocks (form fields), inputs/outputs (data flow)
defineToolkit()Grouping unittools, block, triggers, gets enabled/disabled per org
defineExtension()Top-level containerauth config, provides.toolkits / provides.models, extension metadata

Data flow: The block's subBlocks define the form fields. The block's tools.access array links to tool IDs. When the workflow runs, the platform calls the tool's execute() function with the validated input and a PureContext.

Build the Archive

bunx @scrydon/sdk-authoring extension build

Output:

Archive created: dist/hello-world-1.0.0.archive.tar.gz
  Vendor: Hello World (hello-world)
  Version: 1.0.0
  Products: 1
  Tools: 1
  Dependencies: 1 (SBOM: meta/sbom.cdx.json)

What the Build Does

  1. Imports your entry point and verifies the default export is a defineExtension() result
  2. Extracts all metadata into manifest.json — Zod schemas become JSON Schemas
  3. Archives your code with esbuild into a single dist/index.js (all dependencies inlined)
  4. Generates a CycloneDX 1.6 SBOM listing all archived NPM packages (meta/sbom.cdx.json)
  5. Packages everything into {extensionId}-{version}.archive.tar.gz

Inspect the Output

# List archive contents
tar -tzf dist/hello-world-1.0.0.archive.tar.gz

# Pretty-print the manifest
tar -xzf dist/hello-world-1.0.0.archive.tar.gz -O manifest.json | python3 -m json.tool

Validate Without Uploading

The SDK includes a test command:

# Static validation — checks manifest, schemas, ID formats
bunx @scrydon/sdk-authoring extension test --level static

# Compatibility validation — loads the archive in a local Worker Thread (not microVM attestation)
bunx @scrydon/sdk-authoring extension test --level sandbox

# Test a specific tool
bunx @scrydon/sdk-authoring extension test --level sandbox --tool hello-world:hello-world:say-hello

Upload and Use

Custom extensions are delivered inside a extension — there is no manual archive upload. See Delivering & Managing for detailed instructions. The quick version:

  1. Publish your .archive.tar.gz as an extension entry in an extension (how)
  2. Register that extension's Git/OCI source under Settings > Platform > Extensions > Sources — or, for a one-off, use Upload an extension (one-off) on the same tab
  3. Open Add extension, find "Hello World", and click Install (it declares no credential, so the verb is Install rather than Connect)
  4. Open the Workflow Editor and search for "Hello World"

Next Steps: Add Authentication

Most real extensions need credentials. Here's how to add API key authentication:

// Change the auth config
export default defineExtension({
  // ...
  auth: {
    credentials: {
      apiKey: {
        type: "apiKey",
        label: "API Key",
        description: "Your service API key",
        headerName: "Authorization",
        headerPrefix: "Bearer",
      },
    },
    default: "apiKey",
  },
  // ...
});

Update the block to show an API key input:

const myBlock = defineBlock({
  // ...
  authMode: "apiKey",
  subBlocks: [
    {
      id: "apiKey",
      title: "API Key",
      type: "short-input",
      password: true,
      placeholder: "Enter your API key",
    },
    // ... other fields
  ],
  // ...
});

The key is then available in ctx.auth.apiKey inside your execute() function:

async execute(input, ctx) {
  const response = await fetch("https://api.example.com/data", {
    headers: {
      Authorization: `Bearer ${ctx.auth.apiKey}`,
    },
  });
  const data = await response.json();
  return { success: true, output: data };
},

Next Steps: Multiple Tools

Add a second tool to the same toolkit by defining another defineTool() and including it in the toolkit's tools array:

const reverseTool = defineTool({
  id: "reverse",
  name: "Reverse Message",
  version: "1.0.0",
  description: "Reverses the input message.",
  input: z.object({ message: z.string() }),
  output: z.object({ message: z.string() }),
  params: {
    message: {
      type: "string",
      required: true,
      visibility: "user-or-llm",
      description: "Message to reverse",
    },
  },
  async execute(input, ctx) {
    const reversed = input.message.split("").reverse().join("");
    ctx.logger.info(`Reversed: "${reversed}"`);
    return { success: true, output: { message: reversed } };
  },
});

// Add to the toolkit's tools and its block
const helloToolkit = defineToolkit({
  // ...
  tools: [sayHelloTool, reverseTool],
  triggers: [],
  block: defineBlock({
    // ...
    subBlocks: [
      {
        id: "operation",
        title: "Operation",
        type: "dropdown",
        options: [
          { label: "Say Hello", id: "say-hello" },
          { label: "Reverse", id: "reverse" },
        ],
      },
      // ... message field
    ],
    tools: {
      access: ["say-hello", "reverse"],
      toolSelector: {
        param: "operation",
        map: {
          reverse: "reverse",
          "say-hello": "say-hello",
        },
        default: "say-hello",
      },
    },
  }),
});

The declarative tools.toolSelector dynamically selects which tool to run based on the user's dropdown selection and survives manifest serialization. Function-valued tools.config.tool and tools.config.params fields are runtime-only and are omitted from manifest.json.

On this page

On this page