> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getcargo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Public form

> Expose a tool as a public, embeddable form that runs the tool's workflow on submit

The **public form** turns a tool into a hosted, embeddable web form. Visitors fill it in on your site, and each submission runs the tool's workflow — like a Typeform that triggers a Cargo automation instead of just collecting data.

It is the easiest way to put a tool behind a public surface (landing page, in-product survey, partner site) without writing a backend.

<iframe className="w-full aspect-video rounded-xl" src="https://www.youtube.com/embed/AbDt-0XK-vQ" title="Embed a tool as a public form" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen />

***

## How it works

Every tool already has an **Input** node that defines its data schema (see [Tools overview](/tools/overview)). When the public form is enabled, Cargo hosts a zero-dependency SDK (`@cargo-ai/form-sdk`) that reads those input fields and renders them as a styled form on your page. Each submission runs the same workflow that the **Trigger** tab, plays, and agents run — only the entry point changes.

<Note>
  The tool **must be published** for the public form to accept submissions. The
  form always renders the deployed version, not the working draft.
</Note>

***

## Enabling the public form

Declare the public form on the tool in code — it deploys with `cargo-ai cdk deploy` alongside everything else:

```ts tools/enrich.ts theme={null}
import { defineTool, env } from "@cargo-ai/cdk";

export const enrich = defineTool("enrich", {
  workflow: enrichFlow,
  publicForm: {
    isEnabled: true,
    allowedOrigins: ["https://www.acme.com"],   // exact origins; "*" for fully public
    spam: {
      captchaProvider: "turnstile",             // or "hcaptcha" / null
      captchaSiteKey: "YOUR_SITE_KEY",
      captchaSecret: env("TURNSTILE_SECRET"),
      minFillMillis: 1500,                       // time-trap; 0 disables it
    },
    presentation: null,                          // workspace default theme — see Appearance below
  },
});
```

<Note>
  `captchaSecret` is a plain string in the config (not an encryption field), so
  `secret()` doesn't apply here — use `env()` to keep the value out of your
  repo while still deploying it.
</Note>

Every field below (access, spam protection, appearance) maps to a key in this `publicForm` config. After deploy, grab the embed snippet from the tool and drop it into your page.

<Accordion title="Prefer the UI?">
  Open your tool → **Trigger** tab → **Public form** → **Configure**, toggle
  **Enable public form** on, set access / spam / appearance, and save. The
  **Embed snippet** section then gives you a ready-to-paste `<script>` tag.
</Accordion>

***

## Access

### Allowed origins

The form is gated by an explicit allow-list of browser origins. Requests from any other origin are rejected with `403`.

| Value                  | Effect                                               |
| ---------------------- | ---------------------------------------------------- |
| `https://www.acme.com` | Allow exactly this origin                            |
| One origin per line    | Allow each listed origin                             |
| `*`                    | Allow any origin (use only for fully public widgets) |

<Warning>
  Origin must be an **exact match** — `https://www.acme.com` does **not** allow
  `https://acme.com` or `https://staging.acme.com`. List every host you embed
  on (including local dev URLs like `http://localhost:3000`).
</Warning>

Requests without an `Origin` header (server-to-server calls, `curl`) bypass the check.

***

## Spam protection

Three layers run in order on every submission. Failing any one rejects the request with `400`.

### Honeypot

A hidden decoy field is injected automatically by the SDK. Real users never see it; most spam bots fill every field they find. If it is filled, the submission is dropped. No configuration needed.

### Time-trap

The SDK stamps a render timestamp into the payload. Submissions arriving faster than **Minimum fill time** are rejected as bot traffic.

| Setting                    | Recommended                                     |
| -------------------------- | ----------------------------------------------- |
| **Minimum fill time (ms)** | `1500` for short forms, `3000+` for longer ones |
| **0**                      | Disables the time-trap                          |

### CAPTCHA

When configured, Cargo verifies a CAPTCHA token server-side before running the workflow.

| Provider                 | Where to get keys                                                          |
| ------------------------ | -------------------------------------------------------------------------- |
| **Cloudflare Turnstile** | [Turnstile dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) |
| **hCaptcha**             | [hCaptcha dashboard](https://dashboard.hcaptcha.com/sites)                 |

Configure both fields:

* **Site key** — public, embedded on your page
* **Secret** — server-side only, never sent to the browser

Pass the token from your page into the SDK via `addHiddenFields`:

```html theme={null}
<script>
  Cargo.loadForm("TOOL_UUID", {}, (form) => {
    turnstile.render("#captcha", {
      sitekey: "YOUR_SITE_KEY",
      callback: (token) => {
        form.addHiddenFields({ _cargo_captcha_token: token });
      },
    });
  });
</script>
```

Cargo also has a built-in **per-tool, per-IP rate limit** of 10 submissions per minute. You don't configure it — it's always on.

***

## Appearance

The SDK ships a sensible default stylesheet, but you can theme the form per-workspace so every embed picks up your brand automatically.

| Setting                | Effect                                                                                                               |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Color scheme**       | `Auto` follows the visitor's OS preference, or force `Light` / `Dark`                                                |
| **Primary color**      | Submit button, input focus border and ring, checkbox accent; the button hover shade is derived from it automatically |
| **Primary text color** | Text on top of the primary color (e.g. submit button label)                                                          |
| **Border radius**      | Any CSS length (`6px`, `999px` for fully rounded, …) applied to inputs and the button                                |
| **Font family**        | Any CSS `font-family` value (e.g. `"Inter", system-ui, sans-serif`)                                                  |

Embedders can still override any of these per page via the SDK's `theme` option — workspace defaults are merged with per-call overrides, and explicit overrides win.

In code, `presentation.theme` takes the full theme object — every key is required, and `null` means "use the SDK's bundled default":

```ts theme={null}
presentation: {
  theme: {
    preset: "light",          // "light" | "dark"; null follows the visitor's OS
    primaryColor: "#4f46e5",
    primaryTextColor: null,
    primaryHoverColor: null,
    textColor: null,
    mutedColor: null,
    backgroundColor: null,
    borderColor: null,
    borderFocusColor: null,
    radius: "8px",
    fontFamily: null,
  },
},
```

<Tip>
  Leave a field blank to fall back to the SDK's bundled default. The form
  automatically follows `prefers-color-scheme` unless you force a preset.
</Tip>

***

## Embedding the form

The easiest path is the CDN snippet from the **Embed snippet** section:

```html theme={null}
<div data-cargo-form="TOOL_UUID"></div>
<script>
  window.Cargo = window.Cargo || {};
  Cargo.loadForm = Cargo.loadForm || function () {
    var call = { args: arguments };
    call.promise = new Promise(function (resolve, reject) {
      call.resolve = resolve;
      call.reject = reject;
    });
    (Cargo._q = Cargo._q || []).push(call);
    return call.promise;
  };

  Cargo.loadForm("TOOL_UUID");
</script>
<script src="https://cdn.getcargo.io/forms/v1.js" async></script>
```

That's it — Cargo renders a styled form into the `<div>`, 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}
<script>
  Cargo.loadForm("TOOL_UUID", { mode: "sync" }, (form) => {
    form.addHiddenFields({ page_url: location.href });

    form.onValidate((values) => {
      return typeof values.email === "string" && values.email.includes("@");
    });

    form.onSuccess(() => {
      location.href = "/thank-you";
      return false;
    });
  });
</script>
```

### 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

<AccordionGroup>
  <Accordion title="Keep allowed origins tight">
    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.
  </Accordion>

  <Accordion title="Pair honeypot with CAPTCHA on high-value forms">
    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.
  </Accordion>

  <Accordion title="Use async mode for long workflows">
    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.
  </Accordion>

  <Accordion title="Capture context with hidden fields">
    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.
  </Accordion>

  <Accordion title="Test the embed on your real site">
    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`.
  </Accordion>
</AccordionGroup>
