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-vendorThe 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-extensionbun init -yUpdate package.json:
{
"name": "my-extension",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/index.ts"
}bun add -d @scrydon/sdk-authoring zodWrite 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:
| Layer | What it defines | Key fields |
|---|---|---|
defineTool() | Runtime logic | input (Zod), output (Zod), execute() function |
defineBlock() | Workflow editor UI | subBlocks (form fields), inputs/outputs (data flow) |
defineToolkit() | Grouping unit | tools, block, triggers, gets enabled/disabled per org |
defineExtension() | Top-level container | auth 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 buildOutput:
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
- Imports your entry point and verifies the default export is a
defineExtension()result - Extracts all metadata into
manifest.json— Zod schemas become JSON Schemas - Archives your code with esbuild into a single
dist/index.js(all dependencies inlined) - Generates a CycloneDX 1.6 SBOM listing all archived NPM packages (
meta/sbom.cdx.json) - 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.toolValidate 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-helloUpload and Use
Custom extensions are delivered inside a extension — there is no manual archive upload. See Delivering & Managing for detailed instructions. The quick version:
- Publish your
.archive.tar.gzas anextensionentry in an extension (how) - 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
- Open Add extension, find "Hello World", and click Install (it declares no credential, so the verb is Install rather than Connect)
- 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.