| name | connected-function-developer |
| description | This skill enables Claude to build, modify, and deploy Skedulo custom functions. Custom functions are server-side APIs that run on Skedulo's platform without requiring users to manage their own infrastructure. |
Skedulo Custom Functions Skill
What Are Skedulo Custom Functions?
Custom functions are Skedulo's serverless API platform. They provide stateless, authenticated APIs for custom business logic and third-party integrations.
Key Benefits:
- No server infrastructure to manage
- Automatic authentication via Bearer tokens
- Native integration with Skedulo platform
- Can be triggered by webhooks, actions, or extensions
Core Concepts
Function Structure
Every function has this file structure:
my-function/
├── sked.proj.json # Function configuration and settings
├── state.json # Metadata for CLI operations
├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration
├── src/
│ ├── handler.ts # Main entry point
│ ├── routes.ts # Route definitions
│ └── types.ts # Type definitions
└── .env # Local config variables (not deployed)
sked.proj.json
This file defines the function configuration:
{
"type": "function",
"version": "2",
"name": "my-function-name",
"description": "Clear description of what this function does",
"runtime": "nodejs24.x",
"settings": {
"configVars": [
{
"name": "VARIABLE_NAME",
"configType": "plain-text",
"description": "What this variable is for",
"default": "default-value"
}
]
}
}
Key Properties:
type: Always "function"
version: Always "2"
name: Lowercase with hyphens, describes purpose
description: Clear explanation for administrators
runtime: Use "nodejs18.x"
settings.configVars: Array of configuration variables
Configuration Variables
Configuration variables let administrators customize function behavior without code changes.
Types:
plain-text: Regular string values
secret: Encrypted values (API keys, passwords)
Best Practices:
- Always provide default values when reasonable
- Use descriptive names in SCREAMING_SNAKE_CASE
- Write clear descriptions for administrators
- Access via:
skedContext?.configVars?.getVariableValue("VAR_NAME")
Example:
const apiKey = skedContext?.configVars?.getVariableValue("STRIPE_API_KEY");
if (!apiKey) {
return {
status: 400,
body: { error: "STRIPE_API_KEY not configured" }
};
}
Routes
Routes define the API endpoints. Use the routes.ts file:
import { FunctionRoute } from "@skedulo/sdk-utilities";
import * as pathToRegExp from "path-to-regexp";
export function getCompiledRoutes() {
return getRoutes().map((route) => {
const regex = pathToRegExp(route.path);
return {
regex,
method: route.method,
handler: route.handler,
};
});
}
function getRoutes(): FunctionRoute[] {
return [
{
method: "get",
path: "/health",
handler: async (__, headers, method, path, skedContext) => {
return {
status: 200,
body: { status: "healthy" }
};
},
},
{
method: "post",
path: "/process",
handler: async (body, headers, method, path, skedContext) => {
const result = await processData(body);
return {
status: 200,
body: { result }
};
},
}
];
}
Handler Parameters:
body: Parsed request body (for POST/PUT/PATCH)
headers: Request headers object
method: HTTP method (GET, POST, etc.)
path: Request path
skedContext: Skedulo context with config vars and utilities
Response Format:
{
status: number,
body: object | string,
headers?: object
}
Handler.ts
The main entry point processes requests:
import { Request, Response } from "express";
import { getCompiledRoutes } from "./routes";
import { createSkedContext } from "@skedulo/function-utilities";
export const handler = async (req: Request, res: Response) => {
try {
const skedContext = createSkedContext(req.headers);
const routes = getCompiledRoutes();
const matchedRoute = routes.find((route) => {
return route.method === req.method.toLowerCase() &&
route.regex.test(req.path);
});
if (!matchedRoute) {
res.status(404).json({ error: "Route not found" });
return;
}
const result = await matchedRoute.handler(
req.body,
req.headers,
req.method,
req.path,
skedContext
);
res.status(result.status).json(result.body);
} catch (error) {
console.error("Function error:", error);
res.status(500).json({ error: "Internal server error" });
}
};
Development Workflow
1. Generate Function
sked function generate --name my-function --outputdir .
cd my-function
2. Install Dependencies
yarn bootstrap
3. Develop Locally
yarn compile
sked function dev . -p 3000
curl --location 'http://127.0.0.1:3000/health' \
--header 'Authorization: Bearer <token>'
4. Deploy to Tenant
sked artifacts function upsert -f state.json
sked package deploy -p .
5. Test Deployed Function
sked artifacts function list
curl --location '<baseUrl>/health' \
--header 'Authorization: Bearer <token>'
Common Patterns
REST API Endpoint
{
method: "post",
path: "/api/customers",
handler: async (body, headers, method, path, skedContext) => {
if (!body.email) {
return {
status: 400,
body: { error: "Email required" }
};
}
const customer = await createCustomer(body);
return {
status: 201,
body: { customer }
};
},
}
Webhook Receiver
{
method: "post",
path: "/webhooks/stripe",
handler: async (body, headers, method, path, skedContext) => {
const signature = headers["stripe-signature"];
const secret = skedContext?.configVars?.getVariableValue("STRIPE_WEBHOOK_SECRET");
if (!verifyStripeSignature(body, signature, secret)) {
return {
status: 401,
body: { error: "Invalid signature" }
};
}
await processStripeEvent(body);
return {
status: 200,
body: { received: true }
};
},
}
Integration with Skedulo GraphQL
IMPORTANT: When accessing Skedulo's GraphQL API, ALWAYS use the @skedulo/pulse-solution-services library and the connected-function:skedulo-api-developer skill for guidance.
import { ExecutionContext } from "@skedulo/pulse-solution-services";
{
method: "get",
path: "/jobs/:jobId",
handler: async (__, headers, method, path, skedContext) => {
const jobId = path.split("/").pop();
try {
const context = ExecutionContext.fromContext(skedContext, {
requestSource: "custom-function",
userAgent: "job-fetcher"
});
const result = await context
.newQueryBuilder({
objectName: "Jobs",
operationName: "getJob"
})
.withFields(["UID", "Name", "JobStatus"])
.withFilter(`UID = '${jobId}'`)
.execute();
if (result.records.length === 0) {
return {
status: 404,
body: { error: "Job not found" }
};
}
return {
status: 200,
body: result.records[0]
};
} catch (error) {
console.error("Failed to fetch job:", error);
return {
status: 500,
body: { error: "Failed to fetch job data" }
};
}
},
}
Key Benefits of using @skedulo/pulse-solution-services:
- Type-safe query building
- Automatic pagination support
- Built-in error handling
- Performance monitoring
- Consistent patterns across the platform
When to use the connected-function:skedulo-api-developer skill:
- Any time you need to query or mutate Skedulo data
- When implementing batch operations
- For complex GraphQL queries with relationships
- When performance optimization is needed
- For proper error handling patterns
Error Handling
{
method: "post",
path: "/process",
handler: async (body, headers, method, path, skedContext) => {
try {
const result = await riskyOperation(body);
return {
status: 200,
body: { result }
};
} catch (error) {
console.error("Process error:", error);
if (error.code === "VALIDATION_ERROR") {
return {
status: 400,
body: { error: error.message }
};
}
return {
status: 500,
body: { error: "Processing failed" }
};
}
},
}
Code Generation Guidelines
When Creating New Functions
- Use descriptive names:
customer-validator, stripe-integration, schedule-optimizer
- Write clear descriptions: Help administrators understand the purpose
- Include health check route: Always add a GET /health endpoint
- Set up config vars early: Define any API keys or settings upfront
- Add error handling: Every route should handle errors gracefully
- Use TypeScript types: Define interfaces for request/response bodies
- Use
@skedulo/pulse-solution-services: For all Skedulo API interactions
- Leverage skills: Use
connected-function:skedulo-api-developer skill when accessing Skedulo data
When Modifying Functions
- Preserve existing routes: Don't remove routes without explicit instruction
- Match code style: Follow the patterns already in the file
- Update dependencies: Add new npm packages to package.json if needed
- Test locally first: Always remind user to test with
sked function dev
- Document changes: Update function description if behavior changes
Code Style
{
method: "post",
path: "/validate-email",
handler: async (body, headers, method, path, skedContext) => {
const { email } = body;
if (!email || typeof email !== "string") {
return {
status: 400,
body: { error: "Valid email required" }
};
}
const isValid = validateEmailFormat(email);
return {
status: 200,
body: { email, isValid }
};
},
}
{
method: "post",
path: "/check",
handler: async (body) => {
const result = doSomething(body.data);
return { status: 200, body: result };
},
}
Testing Strategy
Local Testing with .env
Create .env file for local config vars:
STRIPE_API_KEY=sk_test_xxxxx
WEBHOOK_SECRET=whsec_xxxxx
API_ENDPOINT=https://api.example.com
Unit Testing
- Ensure you have at least 80% unit test coverage on call produced
Add to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"jest": "^29.0.0",
"@types/jest": "^29.0.0"
}
}
Create test file:
import { handler } from "./handler";
describe("Function handler", () => {
it("should return health status", async () => {
const req = {
method: "GET",
path: "/health",
headers: {},
body: {}
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
await handler(req as any, res as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ status: "healthy" });
});
});
Security Best Practices
- Always validate input: Check types, required fields, formats
- Use config vars for secrets: Never hardcode API keys
- Verify webhook signatures: For external integrations
- Sanitize user input: Prevent injection attacks
- Use Bearer token auth: Already handled by platform
- Log errors carefully: Don't log sensitive data
Deployment Checklist
Before deploying:
Common Issues
"Cannot find module"
Solution: Run yarn bootstrap to install dependencies
"Config variable not found"
Solution: Add to .env for local testing, or create in tenant settings
"Route not found"
Solution: Check path format and HTTP method match
"Unauthorized"
Solution: Ensure valid Bearer token in Authorization header
Function times out
Solution: Check for infinite loops, optimize database queries, add timeouts
Advanced Features
Path Parameters
{
method: "get",
path: "/users/:userId/jobs/:jobId",
handler: async (__, headers, method, path, skedContext) => {
const pathParts = path.split("/");
const userId = pathParts[2];
const jobId = pathParts[4];
},
}
Query Parameters
{
method: "get",
path: "/search",
handler: async (__, headers, method, path, skedContext) => {
const url = new URL(`http://localhost${path}`);
const query = url.searchParams.get("q");
const limit = parseInt(url.searchParams.get("limit") || "10");
},
}
Custom Headers
{
method: "get",
path: "/data",
handler: async (__, headers, method, path, skedContext) => {
return {
status: 200,
body: { data: "value" },
headers: {
"Cache-Control": "max-age=3600",
"X-Custom-Header": "value"
}
};
},
}
Integration Patterns
Triggered Actions
Functions can be called by Skedulo triggered actions when records change.
Webhooks
External systems can POST to function endpoints.
Web Extensions
Custom UI components can call functions via fetch.
Mobile Extensions
Mobile apps can invoke functions for custom logic.
Triggered Actions
Triggered actions fire automatically when Skedulo records are created, updated, or deleted. Two things are required: a manifest file that tells Skedulo when and what to send, and a handler in your function that processes the payload.
See the full reference: references/triggered-action-handler.md
For handling large record batches without timeouts, see references/event-queueing.md.
Manifest file (src/triggered-actions/*.triggered-action.json)
Each triggered action needs a manifest deployed alongside your function:
{
"metadata": { "type": "TriggeredAction" },
"name": "job-after-updated",
"enabled": true,
"trigger": {
"type": "object_modified",
"schemaName": "Jobs",
"filter": "Operation == 'UPDATE' AND (Current.JobStatus != Previous.JobStatus)"
},
"action": {
"type": "call_url",
"url": "{{SKEDULO_API_URL}}/function/my-function/my-function/triggered-action/job-after-update",
"headers": {
"Authorization": "Bearer {{ SKEDULO_USER_TOKEN }}",
"sked-function-execution-type": "async"
},
"query": "{ UID Name JobStatus AccountId }",
"previousFields": "{ UID JobStatus }"
}
}
trigger.filter — EQL expression controlling when the action fires. Keep it narrow.
action.query — fields sent for the new record. Your handler only sees what's listed here.
action.previousFields — fields sent for the old record (UPDATE/DELETE only).
sked-function-execution-type: async — use for operations that may be slow.
Handler
Use createTriggeredActionHandler<T>(objectName, handler) to parse the payload into a typed TriggerContext:
export const afterUpdateJobHandler = createTriggeredActionHandler<Jobs>(
'Jobs',
async ({ data: { newRecords, mapOldRecord } }) => {
const statusChanges = newRecords.filter(job => {
const old = mapOldRecord[job.UID]
return old && old.JobStatus !== job.JobStatus
})
if (!statusChanges.length) {
return { status: 200, body: { message: 'No relevant changes, skipping' } }
}
await processStatusChanges(statusChanges)
return { status: 200, body: { processed: statusChanges.length } }
}
)
TriggerContext provides:
newRecords — records after the change (empty array for DELETE)
mapOldRecord — previous state keyed by UID
isInsert / isUpdate / isDelete — operation flags
Route convention: POST /triggered-action/{entity-action} (e.g. /triggered-action/job-after-update). The URL in the manifest must match exactly.
Deploying with the CLI
Always ask for the tenant alias before deploying. Deploy the function first, then the manifest:
sked artifacts triggered-action upsert \
-f src/triggered-actions/job-after-update.triggered-action.json \
-a <alias>
sked artifacts triggered-action list -a <alias> --filter name=job-after-update
See references/triggered-action-handler.md for the full CLI reference (upsert, list, get, delete) and the complete deployment workflow.
Resources
Working with Skedulo APIs
Always Use the Proper Library
When your function needs to interact with Skedulo data:
DO:
- Use
@skedulo/pulse-solution-services library
- Initialize ExecutionContext from skedContext
- Use QueryBuilder for GraphQL queries
- Follow patterns from
connected-function:skedulo-api-developer skill
- Handle errors with try-catch blocks
- Log errors for debugging
DON'T:
- Use raw GraphQL queries with string templates
- Skip error handling
- Ignore execution limits
- Hardcode query filters
Example: Adding Skedulo API Access to a Function
cd src/functions/my-function
yarn add @skedulo/pulse-solution-services
import { ExecutionContext } from "@skedulo/pulse-solution-services";
const context = ExecutionContext.fromContext(skedContext, {
requestSource: "my-function",
userAgent: "handler-name"
});
const result = await context
.newQueryBuilder({ objectName: "Jobs", operationName: "fetchJobs" })
.withFields(["UID", "Name"])
.execute();
Summary
When building or modifying Skedulo custom functions:
- Use clear naming and descriptions
- Add configuration variables for flexibility
- Implement proper error handling
- Test locally before deploying
- Follow security best practices
- Keep routes focused and simple
- Document your code
- Use
@skedulo/pulse-solution-services for all Skedulo API access
- Leverage the
skedulo-api-developer skill for API patterns
This skill enables rapid development of production-ready Skedulo functions that integrate seamlessly with the platform.