`, and runs your tool's workflow on submit.
The first few lines are a queueing stub: the CDN script loads with `async`, so it can finish loading before or after your inline code runs. The stub makes `Cargo.loadForm` safe to call immediately — calls are queued and replayed (and their promises resolved) as soon as the bundle loads. Don't remove it, and don't call `Cargo.loadForm` from an inline script without it.
### npm
For SPA or framework projects:
```bash theme={null}
npm install @cargo-ai/form-sdk
```
```ts theme={null}
import { loadForm } from "@cargo-ai/form-sdk";
const form = await loadForm("TOOL_UUID", {
target: "#cargo-form",
mode: "sync",
});
form.onSuccess((values, response) => {
if (response.outcome === "completed") {
console.log("Workflow output:", response.result.output);
}
});
```
### Headless mode
Bring your own UI and let the SDK handle validation, anti-spam metadata and submission:
```ts theme={null}
const form = await loadForm("TOOL_UUID", { render: "headless", mode: "async" });
form.setValues({ email: "ada@example.com" });
await form.submit();
```
You're now in full control of the DOM — the SDK just handles the submission plumbing.
***
## SDK reference
### `loadForm(toolUuid, options?, onReady?)`
Loads the deployed schema and returns a `FormInstance`.
| Option | Default | Description |
| ---------------- | ------------------------------------ | -------------------------------------------------------------------------------------- |
| `render` | `"render"` | `"render"` builds the form DOM; `"headless"` skips DOM rendering |
| `mode` | `"sync"` | `"sync"` waits for and returns the run output; `"async"` returns immediately and polls |
| `target` | `[data-cargo-form="TOOL_UUID"]` | Element or selector to render into |
| `autoCaptureUtm` | `true` | Capture UTM params + page URL automatically into hidden values |
| `submitLabel` | `"Submit"` | Label for the submit button |
| `classPrefix` | `"cargo-form"` | Prefix for every CSS class emitted by the renderer |
| `injectStyles` | `true` (render) / `false` (headless) | Inject the bundled default stylesheet once per page |
| `stylesheetUrl` | — | URL of a custom stylesheet to inject instead of the bundled one |
| `theme` | — | Per-call theme overrides (merged on top of workspace defaults) |
### `FormInstance` lifecycle
| Method | Purpose |
| ------------------------- | --------------------------------------------------------------------------------- |
| `setValues(values)` | Merge values into the form (visible fields when rendered) |
| `addHiddenFields(values)` | Add hidden values sent with the submission (UTMs, lead source, CAPTCHA token, …) |
| `getValues()` | Current values (visible + hidden) |
| `onValidate(handler)` | Register a validation hook; return `false` to block submission |
| `onSubmit(handler)` | Called right before submit; mutate the returned values to change the payload |
| `onSuccess(handler)` | Called after a successful submission; return `false` to suppress default behavior |
| `onError(handler)` | Called when submission fails |
| `submit()` | Programmatically submit (used by headless mode) |
```html theme={null}
```
### Sync vs async submission
| Mode | Returns | Use when |
| --------- | ----------------------------------------------------- | ------------------------------------------------------- |
| `"sync"` | Waits up to **60s**, returns the workflow output | Short workflows (enrichment, scoring) — show the result |
| `"async"` | Returns a `runUuid` immediately; SDK polls for status | Long workflows, or fire-and-forget submissions |
If a sync run exceeds 60s it falls back to async automatically — `onSuccess` receives a `{ outcome: "pending", runUuid }` response you can keep polling.
***
## Privacy
The SDK is privacy-aware by default:
* Respects [Global Privacy Control](https://globalprivacycontrol.org/) (`Sec-GPC: 1`) and `DNT: 1`. When the visitor has opted out, no anonymous id is set and UTM auto-capture is skipped.
* Form submission is always an explicit user action, so opt-out **never** blocks the submission itself — only passive identity stitching is suppressed.
UTM auto-capture (when enabled) reads `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` and `page_url` from the current URL and includes them as hidden values on submit.
***
## Best practices
Use `*` only for widgets that genuinely run everywhere. For anything else,
list each host explicitly — it stops other sites from embedding your form
and burning your credits.
Honeypot + time-trap catch nearly all unsophisticated bots for free. Add
Turnstile or hCaptcha when the form triggers credit-heavy work (AI nodes,
enrichment) or feeds downstream systems like a CRM.
Browsers won't wait long. If your tool routinely takes more than a few
seconds (AI calls, multi-step enrichment), switch to `mode: "async"` and
show a "we'll be in touch" screen instead of a spinner.
Beyond UTMs, pass `page_url`, `referrer`, `plan_tier`, or anything else
your workflow can branch on via `form.addHiddenFields({ ... })`. They're
sent as regular workflow inputs — define matching fields on the tool's
Input node.
Origin / CORS issues only surface in a real browser on a real domain.
Always test the snippet on the page you'll actually embed on, not just
`localhost`.
# Triggering a tool
Source: https://docs.getcargo.ai/tools/triggering
Run a tool from the CLI, on a schedule declared in code, from plays/agents/MCP, over the API, or from the UI
Once a tool is deployed (`cargo-ai project deploy`), you can run it many ways. The code-first paths — the CLI and cron triggers declared in `defineTool` — are covered first; the UI Trigger tab is the same thing with a mouse.
## From the CLI
The CLI is the fastest way to run a deployed tool. Find the tool's `workflowUuid`, then create a run.
```bash theme={null}
cargo-ai orchestration tool list # → workflowUuid
```
### Single run
```bash theme={null}
cargo-ai orchestration run create \
--workflow-uuid
\
--data '{"company_domain":"acme.com","contact_email":"john@acme.com"}' \
--wait-until-finished
```
`run create` works with **tool** workflows only. For plays, use `batch create`
instead — see [Triggering a play](/plays/triggering).
### Bulk run
Run the tool across many records with a single batch:
```bash theme={null}
cargo-ai orchestration batch create \
--workflow-uuid \
--data '{"kind":"records","records":[
{"company_domain":"acme.com","contact_email":"john@acme.com"},
{"company_domain":"globex.io","contact_email":"jane@globex.io"}
]}'
cargo-ai orchestration batch get # poll for completion
```
Ramp batch sizes gradually — 1 record, then 50, then the full set — so
connector rate limits surface before they affect thousands of rows.
## On a schedule (cron triggers)
Declare recurring runs directly on the tool. Triggers live in code, so they're versioned and deployed with everything else:
```ts tools/enrich.ts theme={null}
export const enrich = defineTool("enrich", {
workflow: enrichFlow,
triggers: [
{ cron: "0 9 * * 1", name: "Weekly enrichment" },
{ cron: "0 */6 * * *", name: "Every 6 hours", data: { source: "scheduled" } },
],
});
```
Cron is the only trigger kind tools support. `data` is passed as the run input each time the schedule fires. `cargo-ai project deploy` reconciles the trigger list.
## Inside a play, agent, or MCP server
Tools are meant to be composed. Reference the tool handle in code and the platform wires the rest:
```ts theme={null}
// In a workflow (play or another tool) — call it via `uses`
const flow = defineWorkflow("onboard", { input, output, uses: { enrich } },
({ input, uses }) => uses.enrich({ email: input.email }),
);
// In an agent — expose it as a callable tool
export const sdr = defineAgent("sdr", { uses: [enrich], /* … */ });
// Over MCP — publish it to external AI clients
export const server = defineMcpServer("gtm", { uses: [enrich] });
```
See [Plays](/plays/overview), [Agents](/agents/overview), and [MCP servers](/mcp-servers/overview).
## Over the API
Execute a tool synchronously via REST. If execution exceeds 5 minutes it times out.
```bash theme={null}
curl -X POST "https://api.getcargo.io/v1/tools/{tool_id}/execute?token={your_token}" \
-H "Content-Type: application/json" \
-d '{
"company_domain": "acme.com",
"contact_email": "john@acme.com"
}'
```
```json theme={null}
{
"status": "success",
"createdAt": "2024-01-15T10:30:00Z",
"finishedAt": "2024-01-15T10:30:02Z",
"input": { "company_domain": "acme.com", "contact_email": "john@acme.com" },
"output": { "company_name": "Acme Corporation", "employee_count": 500, "industry": "Technology" }
}
```
### Batch execution
For many records or long-running operations, use the batch API. Creating a
batch returns `202 Accepted` with a `Location` header pointing at the batch.
Poll that URL, or `batch get` with the returned uuid, until the batch reaches
a terminal status.
```bash theme={null}
curl -X POST "https://api.getcargo.io/v1/tools/{tool_id}/batches?token={your_token}" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://your-server.com/cargo-callback",
"webhookSecret": "your-secret",
"data": [
{"company_domain": "acme.com", "contact_email": "john@acme.com"},
{"company_domain": "globex.io", "contact_email": "jane@globex.io"}
]
}'
```
`webhookUrl` is optional. When set, results are POSTed there on completion. `webhookSecret` is also optional — when provided, Cargo signs every delivery with an HMAC-SHA256 signature in the `X-Cargo-Signature` header (`sha256=`). Verify it before trusting a delivery:
```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidSignature(rawBody: string, secret: string, header: string) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
```
Poll for results with the batch UUID, and cancel if needed:
```bash theme={null}
curl "https://api.getcargo.io/v1/tools/{tool_id}/batches/{batch_uuid}?token={your_token}"
curl -X POST "https://api.getcargo.io/v1/tools/{tool_id}/batches/{batch_uuid}/cancel?token={your_token}"
```
## Public form
Expose the tool as a hosted, embeddable web form that runs its workflow on submit. See [Public form](/tools/public-form).
## From the UI
The **Trigger** tab mirrors the CLI: **Single** runs one record, **Bulk** uploads a CSV whose headers map to the tool's input fields. Good for ad-hoc testing without a terminal.
```csv theme={null}
company_domain,contact_email
acme.com,john@acme.com
globex.io,jane@globex.io
```
## Choosing a method
| Method | Best for | Automation level |
| ---------------- | ---------------------------------------- | ---------------- |
| **CLI** | Scripted, ad-hoc, and CI-driven runs | Fully automated |
| **Cron trigger** | Recurring runs declared in code | Fully automated |
| **Plays** | Event-driven, multi-step automation | Fully automated |
| **Agents** | Context-aware, AI-driven invocation | AI-driven |
| **MCP server** | External AI system integration | AI-driven |
| **API** | Custom applications, programmatic access | Fully automated |
| **Trigger tab** | Testing and one-off runs | Manual |
# Using UI
Source: https://docs.getcargo.ai/tools/using-ui
Learn what tools are, and how to build your first one.
## What are tools?
Tools are modular, reusable automation workflows that you can build once and deploy anywhere. They encapsulate repeatable actions—like finding stakeholders, extracting revenue data, or qualifying leads—so you can scale your GTM workflows without rebuilding logic from scratch.
Once published, tools can be:
* **Triggered in plays** to run as part of automated workflows
* **Leveraged by agents** to accomplish complex tasks
* **Deployed to MCP servers** for external integrations
* **Called via API** from your own applications
> The more you invest in your Cargo tools, the more efficient and robust your GTM systems become.
***
## Use cases
Here are some of the most popular ways Cargo customers use tools:
* **Lead qualification:** Automate the process of qualifying leads based on custom criteria and real-time data.
* **Custom data enrichment:** Pull key company metrics (like 2024 revenue) from multiple sources automatically.
* **Stakeholder discovery:** Find and surface the right contacts at target accounts.
* **Outreach automation:** Send emails or trigger other communications as part of a larger workflow.
* **CRM hygiene:** Detect and fix data quality issues like invalid emails or duplicate records.
***
## Step-by-step: Build your first tool
Follow these steps to create a tool that finds stakeholders at a company based on a prompt.
### Step 1: Create a new tool
1. Navigate to **Tools** in your Cargo workspace sidebar
2. Click **+ New Tool** in the top right corner
3. Give your tool a descriptive name (e.g., "Find stakeholders from a prompt")
4. You'll be taken to the tool editor canvas
### Step 2: Define your inputs
Every tool needs input data to work with. Click on the **Start** node to configure your inputs:
| Property | Description |
| ----------------- | ----------------------------------------------------------------------- |
| **Field name** | The identifier for this input (e.g., `company_domain`, `search_prompt`) |
| **Type** | Data type: `string`, `number`, `boolean`, `array`, or `object` |
| **Required** | Whether this field must be provided for the tool to execute |
| **Description** | Help text explaining what this field is for |
| **Default value** | Fallback value if none is provided (optional) |
Use `snake_case` for field names (e.g., `company_name` instead of
`companyName`) for consistency across your workspace.
### Step 3: Add nodes to build your workflow
Click the **+** button to add nodes to your workflow. For this example, we'll build a stakeholder finder:
1. Add an **Instruct** node to convert natural language to structured criteria
2. Add an **Enrich company** node to gather company data
3. Add a **Branch** node to check if the enrichment succeeded
4. Add a **Search leads** node to find relevant contacts
5. Add a **Script** node to format the output
### Step 4: Connect your nodes
To connect nodes, click on the output handle of one node and drag it to the input handle of the next:
* Connections define how data flows through your tool
* Branch nodes have multiple outputs (YES/NO) for conditional logic
* A node's output can connect to multiple downstream nodes
### Step 5: Map data between nodes
Use expressions to pass data from one node to another. In any input field, reference outputs from previous nodes:
```
{{nodes.start.company_domain}}
{{nodes.enrich_company.employee_count}}
{{nodes.instruct.result}}
```
Check the [expressions cheatsheet](/reference/expressions-cheatsheet) for
all available syntax and functions.
### Step 6: Configure your output
Define what your tool returns by configuring the **End** node:
1. Click on the **End** node
2. Map the fields you want to return (e.g., stakeholder names, titles, LinkedIn URLs)
3. These outputs can be used by plays, agents, or external systems calling your tool
### Step 7: Test your tool
Before publishing, validate your tool works correctly:
1. Click the **Test** button in the bottom toolbar
2. Enter sample input data
3. Click **Run** to execute the tool
4. Inspect each node's output in the execution view
5. Fix any errors that appear
Test mode uses real integrations and may consume credits or modify external
systems. Use test data when possible.
### Step 8: Publish your tool
Once everything works, publish your tool to make it available:
1. Click **Publish** in the top right corner
2. Choose a version type (Major, Minor, or Patch)
3. Add a description of your changes
4. Click **Publish** to deploy
Publishing creates a new version. Active plays and agents continue using the
version they were configured with until you explicitly update them.
***
## Managing versions
Tools support full version control so you can iterate safely:
* **Drafts:** Changes are auto-saved as drafts until you publish
* **Version history:** Access previous versions from the dropdown menu
* **Rollback:** Revert to any previous version if needed
***
## Best practices
Rename nodes from defaults like "Branch 1" to something meaningful like
"Check if enterprise tier". Your future self will thank you.
Don't assume every node will succeed. Use Branch nodes to check for errors
and handle them gracefully—log failures, send notifications, or trigger
fallback logic.
Build tools that do one thing well rather than trying to handle every edge
case. You can always chain tools together in plays or let agents orchestrate
multiple tools.
Add descriptions to every input field. When someone else (or future you)
uses this tool, they'll know exactly what data to provide.
Don't just test the happy path. What happens with missing data? Invalid
formats? API rate limits? Test these scenarios before publishing.
***
## Next steps
Browse the 120+ actions in the editor's node palette — and how to list them
from the CLI.
Discover the different ways to trigger your tools.
Track executions, debug failures, and optimize your tools.
Trigger tools directly from Salesforce with custom buttons.
# Workers & apps
Source: https://docs.getcargo.ai/workers-and-apps/overview
Deploy hosted edge workers and Vite single-page apps as part of your workspace with defineWorker and defineApp.
Cargo hosts two kinds of custom code alongside your resources: **workers** (serverless edge HTTP handlers) and **apps** (Vite single-page apps served on `*.cargo.app`). `defineWorker` and `defineApp` deploy a **source bundle directory** that Cargo Hosting builds on deploy.
## Define a worker
```ts workers/webhook.ts theme={null}
import { defineWorker, secret } from "@cargo-ai/cdk";
// bundle root needs: manifest.json + package.json + package-lock.json,
// plus a TS entry `src/index.ts` (or a pre-built `index.js`)
export const webhook = defineWorker("webhook", {
path: new URL("./webhook", import.meta.url).pathname,
// env vars baked into the build; secret("NAME") reads the value at deploy
// time and stores it as a secret env var
env: {
CARGO_API_TOKEN: secret("CARGO_API_TOKEN"),
LOG_LEVEL: "info",
},
// optional: call the worker on a schedule
triggers: [
{
name: "Sync",
cron: "@every 1h",
path: "/cron/sync",
data: { mode: "incremental" },
headers: { authorization: "Bearer " },
},
],
});
```
## Define an app
```ts apps/dashboard.ts theme={null}
import { defineApp } from "@cargo-ai/cdk";
// bundle root needs: index.html + package.json + package-lock.json (a Vite app)
export const dashboard = defineApp("dashboard", {
path: new URL("./dashboard", import.meta.url).pathname,
});
```
Cargo validates the required bundle files exist at define time, so a missing file fails in `plan`.
## Environment variables
A worker reads its variables from `c.env`, an app from `import.meta.env.VITE_*`. Both are baked in when the deployment is built, so a change needs a redeploy.
Set them on the resource — through `env` above, or its Environment tab in the Cargo app — or once for the whole workspace under **Settings → Environment**, which every worker and app inherits without declaring anything. A key set on the resource overrides the workspace value; an app only inherits non-secret `VITE_` keys, since its bundle is served to the browser. See [Secrets & environments](/reference/secrets-and-environments#workspace-environment-variables).
Because the inheritance is automatic, a resource's `env` takes `env()` or `secret()` and *not* `workspaceEnv()` — declaring a pointer here would only restate a variable the worker or app already receives. Leave a workspace variable out of `env` entirely.
`defineWorker`/`defineApp` are the deployable **resource** (the hosted slot).
Author the worker's runtime code in TypeScript with `createWorker` from
`@cargo-ai/worker-sdk` at `src/index.ts` — bundles are uploaded as source and
built server-side: the hosting build runs `npm ci` then esbuilds the worker
entrypoint (esbuild transpiles TypeScript natively) or `vite build` for apps.
A pre-built `index.js` at the bundle root is still accepted for backwards
compatibility. Bundle sub-directories have their own `package.json`, so the
loader treats them as content to upload, not resource files to import.
## Cron triggers
A worker can be called on a schedule: each trigger describes the full request Cargo fires — cron (or Temporal's `@every` shorthand), path, method (default `POST`), a JSON body, and headers. Set an `authorization` header and guard the route with standard Hono middleware (`bearerAuth`/`basicAuth`); ticks only fire once the worker has a promoted deployment.
Triggers can be set from the worker's page in the Cargo app, via `defineWorker` (above), or with the CLI/API:
```bash theme={null}
cargo-ai hosting worker update --uuid \
--triggers '[{"type":"cron","name":"Sync","cron":"@every 1h","path":"/cron/sync","method":"POST"}]'
```
## Calling the Cargo API
Create a workspace API token and add it to the worker as a `CARGO_API_TOKEN` secret environment variable — the Cargo API host is allowed without an `outboundAllowlist` entry. `createCargoApi(env)` from `@cargo-ai/worker-sdk` returns the fully-typed `@cargo-ai/api` client:
```ts theme={null}
import { createCargoApi } from "@cargo-ai/worker-sdk";
app.get("/workers", async (c) => {
const api = createCargoApi(c.env);
const { workers } = await api.hosting.worker.list();
return c.json(workers);
});
```
## Custom integrations
A worker that serves the Custom Integration HTTP contract (`createCustomIntegration` from `@cargo-ai/worker-sdk`) can be registered in the connector catalog declaratively:
```ts theme={null}
import { defineCustomIntegration } from "@cargo-ai/cdk";
export const integration = defineCustomIntegration("my-integration", {
worker: webhook,
});
```
## Scaffolding and deploying standalone
You can also scaffold and ship bundles directly with the CLI:
```bash theme={null}
cargo-ai hosting app init my-app # scaffold a Vite app
cargo-ai hosting worker init my-worker # scaffold an edge worker
cargo-ai hosting deployment create --app-uuid --source .
cargo-ai hosting deployment promote --uuid
```
Slugs are kebab-case and must start with a letter (`my-worker`).
# Workflows
Source: https://docs.getcargo.ai/workflows/overview
defineWorkflow — the TypeScript DSL that backs tools and plays. Author logic in code that compiles to the same nodes as the visual editor.
A **workflow** is the logic that a [tool](/tools/overview) runs or a [play](/plays/overview) executes per row. You author it in TypeScript with `defineWorkflow` from `@cargo-ai/cdk` (re-exported from `@cargo-ai/workflow-sdk`).
Workflows authored in code and workflows drawn on the canvas compile to the **same** artifact and run identically — the code DSL is just a different authoring surface.
## Define a workflow
```ts theme={null}
import { defineWorkflow } from "@cargo-ai/cdk";
import { z } from "zod";
import { enricher } from "../agents/enricher";
export const enrichFlow = defineWorkflow(
"enrich-contact",
{
input: z.object({ email: z.string() }),
output: z.object({ company: z.string(), enriched: z.boolean() }),
uses: { enricher }, // tools/agents this workflow calls, by handle
},
({ input, uses, ai }) => {
// An agent call resolves to `{ answer, evaluation? }` — read `.answer`.
const company = uses.enricher({
prompt: `Which company owns the email domain of ${input.email}?`,
}).answer;
return { company, enriched: true };
},
);
```
* `input` / `output` are Zod schemas — they become the workflow's form fields and end variables.
* `uses` declares the tools, agents, and connectors this workflow calls (see [below](#reference-tools-agents-and-connectors)).
* The **body is parsed, not executed.** The SDK reads the function's source and lowers it to workflow nodes; it never runs at build time. That's why the body can't be `async` and why only a subset of JavaScript is supported (see below) — anything that must run at runtime goes in a `js()` block.
## Reference tools, agents, and connectors
Everything a workflow calls that isn't a native action — a **tool**, an **agent**, or a **connector** — is declared once in the header's `uses` map and called by key in the body. Keeping the reference in the header (not inline in the body) lets the deploy order the dependency and inject the real uuid.
```ts theme={null}
import { connectorRef } from "@cargo-ai/workflow-sdk";
defineWorkflow(
"qualify-lead",
{
input: z.object({ email: z.string() }),
output: z.unknown(),
uses: {
enrich: enrichTool, // a defineTool handle
sdr: sdrAgent, // a defineAgent handle
hubspot: hubspotConnector, // a defineConnector handle
hunter: connectorRef("6f0c…", "hunter"), // …or a connector by uuid + slug
},
},
({ input, uses }) => {
const verified = uses.enrich({ email: input.email }); // tool → call by key
const contact = uses.hubspot.upsertContact({ email: input.email }); // connector → call an action
return contact;
},
);
```
* **Tools and agents** are called directly — `uses.(input)`.
* **Connectors** expose their actions — `uses..(input)`. Action names and their input/output are typed from the integration's schema (run `cargo-ai project types`). The type comes from the integration **slug**, not the uuid — so the connector's uuid can be a handle for a resource that doesn't exist yet.
* Pass a `defineTool` / `defineAgent` / `defineConnector` handle directly, or use `toolRef` / `agentRef` / `connectorRef(uuid, slug)` for a resource referenced by literal uuid.
## What you can express in code
| Capability | In code |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Call a tool / agent / connector | `uses.(input)` (tool / agent) or `uses..(input)` (connector) — [declared in `uses`](#reference-tools-agents-and-connectors) |
| Storage actions | `model.search / upsert / update / insert / remove / record / ask / customColumn` |
| Native helpers | `python(input)`, `scoring(input)`, `allocate(input)`, `delay(input)`, `fileSearch(input)`, `sendEmail(input)`, `waitEmailEvent(input)` |
| Branch | `if (cond) { … } else { … }` |
| Switch | `if … else if … else …` |
| Loop over a list | `for (const x of items) { … }` |
| Inline AI value | `ai(\`prompt \$\`)\` |
| Custom JavaScript | `js(({ nodes }) => …)` |
| Human approval | `humanReview(config, { approved, declined })` |
| Memory (KV) | `memory.get / set / getOrSet / remove / increment / decrement` |
| Traffic split / balance | `split(30, { left, right })`, `balance([...])` |
Supported statements: `const` / `let` (single identifier), `if` / `else if` / `else`, `for…of`, and `return`. Not supported: `async`/`await`, `try/catch`, `throw`, closures, nested function declarations, and destructuring assignments.
## Built-in actions
### `ai()` — inline AI value
Ask an LLM for a single value anywhere an expression fits:
```ts theme={null}
const summary = ai(`Summarize what ${input.domain} sells in one sentence.`);
```
### `js()` — runtime JavaScript
Escape hatch for logic the parser can't lower — the function you pass **does** run at runtime, as a script node, with access to upstream node outputs:
```ts theme={null}
const normalized = js(({ nodes }) => {
const raw = nodes.enricher.answer;
return raw.trim().toLowerCase();
});
```
Use `js()` for transformations, parsing, and computation; keep orchestration (branches, loops, action calls) in the DSL where it compiles to inspectable nodes. `python(...)` is the same idea in Python.
Inside the sandbox, `require()` is limited to `axios`, `cheerio`, `crypto-js`, `date-fns`, `jsonschema`, `lodash`, `url`, `uuid`, and `zod`. Anything else throws, including `knex` — call HTTP APIs with `axios`.
### `sendEmail()` — send from a Cargo mailbox
Deliver one message from a mailbox the workspace owns. Orchestration paces the send against that mailbox's daily allowance. See [Mailboxes](/mailboxes/overview) and [Sending](/mailboxes/sending).
```ts theme={null}
const sent = sendEmail({
mailboxUuid: "11111111-1111-1111-1111-111111111111",
to: input.email,
subject: `Quick note, ${input.firstName}`,
bodyHtml: `Hi ${input.firstName},
`,
});
```
### `waitEmailEvent()` — pause until the thread moves
After `sendEmail`, wait until the conversation is replied to, viewed, interacted with, or unsubscribed. Matching is **per thread**: an event on any send in that conversation completes the wait. If `timeoutHours` elapses first, the node **fails** the run — same as a human-review timeout.
```ts theme={null}
const sent = sendEmail({
mailboxUuid: "11111111-1111-1111-1111-111111111111",
to: input.email,
subject: `Quick note, ${input.firstName}`,
bodyHtml: `Hi ${input.firstName},
`,
});
waitEmailEvent({
messageUuid: sent.messageUuid,
kind: "replied",
timeoutHours: 72,
});
```
| Field | Required | Meaning |
| -------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `messageUuid` | yes | Usually `sent.messageUuid`. The wait loads that message and keys off its `threadUuid`. |
| `kind` | yes | `replied`, `opened` (shown as When viewed), `unsubscribed`, or `interacted` (view, click, reply). |
| `timeoutHours` | no | Hours to wait before failing. Defaults to 72, capped at 720 (30 days). |
If the activity already happened, the node completes immediately — it does not wait.
### `humanReview()` — human-in-the-loop gate
Posts a Slack message with Approve / Decline buttons and **blocks the run** until a reviewer clicks one (or the timeout fires — timeouts auto-decline):
```ts theme={null}
humanReview(
{
connectorUuid: slack.uuid, // the Slack connector to post through
channelId: "C0123456789",
title: "Approve outreach?",
content: `Sending sequence to *${input.email}*`,
timeoutMilliseconds: 86_400_000, // default: 24 hours
enableEditButton: true, // default: true
},
{
approved: () =>
uses.lemlist.createLead({ campaignId: "cam_123", email: input.email }),
declined: () =>
memory.increment({
key: "declined",
expiresIn: { value: 30, unit: "day" },
}),
},
);
```
### `memory.*` — key-value state
Persist state across runs without a model. Each call is one node and returns `{ result }`:
```ts theme={null}
const seen = memory.get({ key: input.email });
if (seen.result === null) {
memory.set({
key: input.email,
value: 1,
expiresIn: { value: 7, unit: "day" },
});
}
```
* `scope: "workflow"` (default) is private to this workflow; `"workspace"` is shared across all workflows.
* `set`, `getOrSet`, `increment`, and `decrement` require `expiresIn` (`{ value, unit }` with `second` / `minute` / `hour` / `day`).
* `increment` / `decrement` are atomic counters — the safe way to rate-limit or cap sends.
### `split()` and `balance()` — traffic shaping
```ts theme={null}
// A/B: ~30% of records run left, the rest right
split(30, {
left: () =>
uses.lemlist.createLead({ campaignId: "cam_A", email: input.email }),
right: () =>
uses.lemlist.createLead({ campaignId: "cam_B", email: input.email }),
});
// Round-robin: each record runs exactly ONE route; returns the first non-null output
const result = balance([
() => uses.hunter.findEmail({ domain: input.domain }),
() => uses.dropcontact.findEmail({ website: input.domain }),
]);
```
Route bodies must be inline zero-argument arrows.
## The action catalog
Cargo ships **120+ actions** across enrichment, CRM, AI, communication, and logic. Three ways to browse what's callable:
```bash theme={null}
cargo-ai connection integration list # every integration and its slug
cargo-ai connection integration get hubspot # one integration's actions + config schemas
cargo-ai project types # generate the typed uses.. registry
```
* **In code**, `cargo-ai project types` writes the registry into `.cargo-ai/`, so a connector declared in `uses` (`uses..`) autocompletes real action slugs and config shapes in your editor.
* **In the UI**, the same catalog is the node palette in the [tool editor](/tools/using-ui) — searchable by category, with per-field documentation inline.
* **Per integration**, each page in the [Integrations tab](/integration/overview) documents its actions, parameters, and credit costs.
## Code covers a subset of the canvas
The code DSL is designed for linear and branching tool/play logic. It does **not** yet mirror the entire visual node palette. These are **canvas-only** today — build them in the [tool editor](/tools/using-ui) if you need them:
* **Filter**, **Delay**, and **Variables** nodes
* **Model** actions — search, insert, update, upsert, remove, custom column
* **File search** and **Model ask** standalone AI nodes
* **Allocate** (lead routing)
Because both paths compile to the same node format, you can start a workflow
in code and finish complex, canvas-only parts in the UI, or vice versa. But
the project never reads UI edits back into your files — once you finish a workflow
in the UI, re-deploying a changed code definition overwrites the canvas work.
See [Code and UI round-trips](/deploy/state-and-drift#code-and-ui-round-trips)
before mixing the two on one resource.
## Retry and failure options
Every connector, tool, and agent call accepts a second options argument:
```ts theme={null}
uses.hunter.findEmail(
{ domain },
{
retry: { maximumAttempts: 3, initialInterval: 1000, backoffCoefficient: 2 },
continueOnFailure: true,
},
);
```
## Deploying workflow changes
A workflow is deployed as part of the tool or play that owns it (`cargo-ai project deploy`). You can also push a Workflow SDK module to an existing workflow directly:
```bash theme={null}
cargo-ai orchestration release deploy-draft --file ./enrichFlow.ts
```