@cargo-ai/workflow-sdk
v1.0.6
Published
Define Cargo workflows in TypeScript. Compiles to the same Node[] artifact the canvas produces and runs on the existing engine.
Readme
@cargo-ai/workflow-sdk
Define Cargo workflows in TypeScript.
import { z } from "zod";
import { defineWorkflow, connectorRef } from "@cargo-ai/workflow-sdk";
export default defineWorkflow(
"name-and-domain-to-email",
{
input: z.object({
first_name: z.string(),
last_name: z.string(),
domain: z.string(),
}),
output: z.object({ email: z.string().nullable() }),
uses: {
hunter: connectorRef("7eff455t-…", "hunter"),
leadmagic: connectorRef("9c26d1f2-…", "leadmagic"),
},
},
({ input, uses }) => {
const hunter = uses.hunter.findEmail({
first_name: input.first_name,
last_name: input.last_name,
domain: input.domain,
});
const leadmagic = uses.leadmagic.findEmail({
firstName: input.first_name,
lastName: input.last_name,
domain: input.domain,
});
return { email: hunter.email ?? leadmagic.email ?? null };
},
);The compiled output is a Node[] JSON artifact that the existing Cargo engine accepts via the same cargo-ai orchestration release deploy-draft CLI canvas users run today. Workflows authored in TypeScript and workflows authored on the canvas run identically.
Heads up: the body callback is parsed, not executed. The SDK reads the function's source, rewrites it into the engine's wire format, and ships it verbatim — so anything you write inside the body must be a supported JS expression (see Supported JS below). Closure variables, asynchronous code, throws, and
console.logcalls don't apply here.
Deploying
Deploy a workflow module straight from source with the CLI — it compiles the TypeScript and ships the resulting nodes as a new release:
# Preview compiled nodes + form fields (no API calls)
cargo-ai orchestration release deploy-draft --file ./my-workflow.ts --dry-run
# Deploy to an existing workflow (minor version auto-bumped)
cargo-ai orchestration release deploy-draft --file ./my-workflow.ts \
--slug name-and-domain-to-emailThe CLI uses --slug (or --workflow-uuid) to target the right workflow. It then runs the module through defineWorkflow, emits the Node[] graph, and POSTs it to the same release endpoint the canvas uses.
Tutorial
1. Define inputs and outputs
defineWorkflow(slug, options, build) is the only entry point. options.input is a Zod object schema that becomes the workflow's form fields (one per top-level key). options.output is a Zod schema describing the workflow's return shape. The build callback receives a scope handle and returns the workflow's result.
defineWorkflow(
"score-lead",
{
input: z.object({
first_name: z.string(),
score: z.number(),
}),
output: z.string().nullable(),
},
({ input }) => input.first_name,
);2. Call tools / agents / connectors / native helpers
The scope handle exposes the model.* storage namespace and the standalone native helpers (allocate, delay, python, scoring, fileSearch) plus the input handle (input), the uses map (tools/agents/connectors you declared in the header — see below), the inline ai() helper, the js() escape hatch, and the flow helpers (balance, split, humanReview, memory — see section 7). There is no native slug registry and no integrations registry in the scope. Each call returns a typed handle you can dot-access — but the call itself is captured at parse time and emitted as a workflow node:
defineWorkflow(
"find-and-verify",
{
input: z.object({ first_name: z.string(), domain: z.string() }),
output: z.string().nullable(),
uses: {
hunter: connectorRef("9c26d1f2-…", "hunter"), // a connector, by handle
validate: toolRef("3e0c5fde-…"), // a tool, by handle (see §2)
},
},
({ input, uses }) => {
const found = uses.hunter.findEmail({
first_name: input.first_name,
domain: input.domain,
});
const verified = uses.validate({ email: found.email });
return verified.is_safe ? found.email : null;
},
);The example above emits two nodes (a hunter.findEmail connector and a validate tool) wired into a chain ending at the workflow's end node. Tools, agents, and connectors have no slug registry — reference them by handle through uses (next). Each uses.<key> call is typed to the referenced resource's input and output — uses.validate({ email }) requires email and returns a Ref you can dot-access (verified.is_safe).
Native platform actions are called the same way, through their hand-written surfaces — the model.* storage namespace and the standalone helpers:
({ input, model, allocate }) => {
const accounts = model.search({ modelUuid: input.modelUuid, limit: 50 });
const result = allocate({
recordId: input.recordId,
type: "territory",
territoryUuid: input.territoryUuid,
});
return result.member;
};Note: there is no runtime
integrationsbody registry to populate — connectors are called by handle throughuses, not through a bundled registry. Runcargo-ai cdk typesto generate theIntegrationstypes for your workspace, so a connector declared inusesautocompletes its real actions (see Populating the registries). The platform's native actions are hand-written, strongly-typed surfaces that ship with the SDK (model.*and the standalone helpers) — no sync needed. Tools, agents, and connectors are all referenced by handle viauses.
The tools / agents registry keys are template slugs — workspace tools and agents have no slug of their own, only the template they were created from. To reference a specific tool / agent (a workspace instance with no usable template slug, or a @cargo-ai/cdk defineTool / defineAgent handle), declare it in the workflow's uses and call it by key — so the uuid stays in the header, not scattered through the body:
import { toolRef, agentRef } from "@cargo-ai/workflow-sdk";
defineWorkflow(
"qualify",
{
input: z.object({ email: z.string() }),
output: z.object({ verified: z.unknown(), answer: z.unknown() }),
uses: {
validate: toolRef("3e0c5fde-9f0e-4f60-b4cb-8a4e29d8a001"),
qualify: agentRef("7b7a2f64-2f5c-49c0-9c19-d6a01a2b4c11"),
},
},
({ input, uses }) => {
const verified = uses.validate({ email: input.email });
const answer = uses.qualify({ prompt: `qualify ${input.email}` });
return { verified, answer };
},
);uses accepts any { uuid, resource } handle. toolRef(uuid) / agentRef(uuid) are the escape hatch for a raw uuid; a @cargo-ai/cdk handle is already such a ref, so pass it directly (uses: { enrich: enrichTool }) — the deploy then orders the dependency and injects the real uuid.
Connectors in uses — the way to call a connector
A connector is always declared in uses and called as uses.<key>.<action>(input) — there is no integrations body registry. Declare it with a @cargo-ai/cdk defineConnector handle or with connectorRef(uuid, integrationSlug), then call its actions by key. This pins that specific connector instance (its uuid rides on the emitted node instead of being resolved from the slug at run time) — which is exactly what you want when there are two HubSpot connectors (prod + sandbox), when you need a reproducible pin, or when you're wiring a CDK handle:
import { connectorRef } from "@cargo-ai/workflow-sdk";
defineWorkflow(
"sync-contact",
{
input: z.object({ email: z.string() }),
output: z.object({ ok: z.unknown() }),
uses: {
// a specific connector — or pass a `defineConnector` handle directly
hs: connectorRef("9c26d1f2-…", "hubspot"),
},
},
({ input, uses }) => {
// a connector exposes its actions: `uses.<key>.<actionSlug>(input)`
const ok = uses.hs.upsertContact({ email: input.email });
return { ok };
},
);Unlike a tool/agent (a single callable uses.<key>(input)), a connector is an action map — uses.<key>.<actionSlug>(input) — typed against the integration's synced action schemas (run cargo-ai cdk types) when the slug is known, or a loose map otherwise. A @cargo-ai/cdk defineConnector handle is already a connector ref, so uses: { hs: hubspot } works and the deploy orders the connector before this workflow and injects its real uuid.
Per-call options — retry + fallback. Any registry call takes an optional second argument to set a node's retry policy and/or let a failure continue instead of halting the run:
// uses: { hunter: connectorRef("9c26d1f2-…", "hunter") }
({ input, uses }) => {
// retry up to 3 times; on final failure, continue to the next node
const email = uses.hunter.findEmail(
{ domain: input.domain },
{ retry: 3, continueOnFailure: true },
);
return email;
};retry is either a positive integer (shorthand for maximumAttempts) or { maximumAttempts?, initialInterval?, backoffCoefficient? } (intervals in milliseconds). continueOnFailure: true routes a failure to the node's normal successor (the last node falls through to the workflow's end) instead of stopping the run. Both values must be static literals. The same (input, options) shape works on uses.*, model.*, the native helpers, and memory.*.
3. Use idiomatic JS for control flow
The parser lowers familiar JS control-flow constructs into the corresponding native nodes:
if (a) { … } else { … }→branchnodeif (a) … else if (b) … else …→switchnode with one route perelse iffor (const item of array) { … }→groupnode iterating overarrayreturn exprinside a block ends that branch withexpr
defineWorkflow(
"qualify",
{
input: z.object({ score: z.number() }),
output: z.string().nullable(),
},
({ input, ai }) => {
if (input.score > 80) {
return ai("write a VIP greeting");
} else if (input.score > 50) {
return ai("write a standard greeting");
} else {
return null;
}
},
);4. Plain JS expressions ship verbatim
Anywhere you'd write a value — an argument to an integration call, the right-hand side of a const, a return expression, an if condition — you can use a plain JS expression built from the supported subset listed below. The SDK rewrites identifiers to the matching nodes.<slug> path and ships the printed source string to the engine as {{ <expr> }}.
defineWorkflow(
"compose",
{
input: z.object({ first: z.string(), last: z.string() }),
output: z.string(),
},
({ input }) => `${input.first.toLowerCase()} ${input.last.toLowerCase()}`,
);The body above compiles to a single end.variables[0] whose value is {{ \${nodes.start.first.toLowerCase()} ${nodes.start.last.toLowerCase()}` }}`.
5. Inline JS via js(({ nodes }) => …)
For logic that doesn't fit the supported expression subset (custom array manipulation, multi-statement transforms, etc.), emit a native.script node with js():
defineWorkflow(
"shape",
{
input: z.object({}),
output: z.array(z.object({ x: z.number() })),
uses: { hunter: connectorRef("9c26d1f2-…", "hunter") },
},
({ uses, js }) => {
const search = uses.hunter.findEmail({});
return js(({ nodes }) => {
return nodes.search.results.map((r: { y: number }) => ({ x: r.y }));
});
},
);js() ships the function's body verbatim. The function may not be async, and the runtime exposes only the { nodes, parentNodes } arguments — closure variables are not in scope.
6. Inline AI completions via ai("…")
ai() is shorthand for an inline AI-completed expression. It accepts a single template string or a string-concatenation expression and emits a templateExpression with instructTo: "ai". The engine runs it through the same AI pipeline the canvas does.
defineWorkflow(
"describe",
{
input: z.object({ company: z.string() }),
output: z.string().nullable(),
},
({ input, ai }) => ai(`write a one-sentence description of ${input.company}`),
);7. Flow helpers — balance, split, humanReview, memory
Some engine nodes have no JS-syntax equivalent, so the scope exposes them as helper functions the parser recognizes by name. Like everything else in the body, the calls are parsed, not executed — route bodies must be inline zero-argument arrow / function literals.
balance([...]) — round-robin. Each record runs exactly one route; the return value coalesces every route's output:
// uses: { hunter: connectorRef("9c26d1f2-…", "hunter"), leadmagic: connectorRef("9c26d1f2-…", "leadmagic") }
({ input, uses, balance }) => {
const found = balance([
() => uses.hunter.findEmail({ domain: input.domain }),
() =>
uses.leadmagic.findEmail({
firstName: input.first,
lastName: input.last,
}),
]);
return found;
};split(percentage, { left, right }) — random A/B split. Records roll ∈ [0, 100); rolls below percentage run left, the rest run right:
// uses: { hunter: connectorRef("9c26d1f2-…", "hunter"), leadmagic: connectorRef("9c26d1f2-…", "leadmagic") }
({ uses, split }) =>
split(30, {
left: () => uses.hunter.findEmail({}),
right: () => uses.leadmagic.findEmail({ firstName: "a", lastName: "b" }),
});humanReview(config, bodies?) — posts a Slack message and blocks until a reviewer clicks Approve or Decline (or the timeout fires). Optional approved / declined bodies run on the matching outcome:
// uses: { hunter: connectorRef("9c26d1f2-…", "hunter") }
({ input, uses, humanReview }) =>
humanReview(
{
connectorUuid: "<slack-connector-uuid>",
channelId: "C0123456789",
title: "Approve outreach?",
content: input.email,
},
{
approved: () => uses.hunter.verifyEmail({ email: input.email }),
},
);memory.<action>(opts) — workflow / workspace key-value store. Actions: get, set, remove, increment, decrement, getOrSet. Each call emits one node and returns a Ref<{ result: T }>:
({ memory }) => {
const count = memory.increment({
key: "emails_sent",
expiresIn: { value: 1, unit: "day" },
});
return count.result;
};expiresIn is required for set, getOrSet, increment, and decrement; scope defaults to "workflow" (pass "workspace" to share across workflows).
Supported JS
The body callback is parsed. Each expression position must be one of:
| Form | Example |
| --------------------------------- | ------------------------------------------------------------------------------- |
| Literal | 42, "alice", true, null, /regex/i |
| Identifier in scope | input, a local const / let, a for…of loop variable |
| Member access | input.score, enriched.email, rows[0] |
| Optional chaining | enriched?.email |
| Binary operators | a + b, a > b, a === b, a % b |
| Logical operators | a && b, a \|\| b, a ?? b |
| Unary operators | !a, -x, typeof a |
| Ternary | a > 0 ? "yes" : "no" |
| Template literal | `hi ${name}` |
| Object / array literals | { a: 1, b: input.x }, [1, 2, ...rest] |
| Method call on a value | email.includes("@"), /re/.test(s) |
| Registry call | uses.<key>.<action>({...}), uses.validate(…), model.search(…), delay(…) |
| ai("…") / js((rt) => …) | inline AI expression / inline script node |
| Flow helper call | balance([…]), split(…), humanReview(…), memory.get(…) |
| new of an engine-allowed global | new Date(s) |
Statement-level forms supported inside the body:
const/letdeclarations binding a single identifier (no destructuring)if/else if/elsefor (const x of array) { … }return <expr>/return;
Anything else (closures, try/catch, await, throw, function declarations, destructuring) is rejected at compile time with a clear error.
Toolchain requirements
The SDK reads your workflow body with Function.prototype.toString() and parses that source. The function the JS engine holds must therefore be the code you wrote:
- Compile workflow files with a modern target (ES2022 or later). Down-leveling rewrites
for…of, template literals, and optional chaining into forms the parser rejects — and worse, into code you didn't write. - Don't instrument workflow files. Code-coverage tools (istanbul/nyc, vitest's default V8 coverage is fine) inject counters into the function source and break parsing. Exclude workflow definition files from instrumentation.
- Standard TypeScript type syntax is fine —
tscand esbuild strip types without touching the runtime code.
If the body can't be parsed, defineWorkflow throws a could not parse the workflow body error explaining this constraint.
Populating the registries
Your workspace's connector types come from cargo-ai cdk types, which reads your workspace over the API and writes:
.cargo-ai/cargo-types.d.ts—declare moduleaugmentations. It adds your workspace's integration slugs to theIntegrationsinterface (with input types generated from each action's schema) — now used to typeuses.<key>.<action>({ … })so a connector declared inusestypechecks against the real shape — and also types@cargo-ai/cdk'sdefineConnector/defineModelconfig against your connector/extractor schemas
Add .cargo-ai to your tsconfig.json include with an explicit glob (".cargo-ai/**/*.d.ts" — a bare dot-dir path is ignored by TypeScript). Re-run cargo-ai cdk types whenever the workspace's connectors change.
Tools and agents are not synced — they're referenced by handle through defineWorkflow's uses (a @cargo-ai/cdk defineTool/defineAgent handle, or toolRef/agentRef("<uuid>")). The platform's native actions are not synced either: they're identical in every workspace, so they ship with the SDK as hand-written, strongly-typed surfaces (model.* and the standalone helpers).
Status
Early access. Ships defineWorkflow plus the hand-written native surfaces (model.* and the standalone helpers) and the handle-based uses surface (tools, agents, connectors) described above. Canvas-side rendering of SDK-authored workflows works because the wire format is identical.
Node types the SDK can express today:
| Engine node | How you write it |
| -------------------------- | -------------------------------------------------------------------------------- |
| connector | uses.<key>.<actionSlug>({ … }) (declared via connectorRef / a CDK handle) |
| tool | uses.<key>({ … }) (declared via toolRef / a CDK handle) |
| agent | uses.<key>({ … }) (declared via agentRef / a CDK handle) |
| native (storage) | model.search / upsert / update / insert / remove / record / ask / customColumn |
| native (data helpers) | allocate / delay / python / scoring / fileSearch |
| native.branch / switch | if / else if / else |
| native.group | for (const x of items) { … } |
| native.script | js((runtime) => …) |
| native.balance | balance([() => …, () => …]) |
| native.split | split(percentage, { left, right }) |
| native.humanReview | humanReview(config, { approved?, declined? }) |
| native.memory | memory.get / set / remove / increment / decrement / getOrSet |
| start / end | implicit (defineWorkflow frames every workflow) |
