# Workflow authoring


A workflow can follow different steps depending on its data. Add a
`logic.condition` block, define its rules, and connect each outcome using an
edge whose `sourceHandle` is `yes` or `no`.

## Save and publish a graph

Create a workflow using `POST /v1/workflows` (MCP `manage_workflows`, create action) with a
name and the `blank` recipe. Use its ID in `PUT /v1/workflows/{id}`
(MCP `manage_workflows`, save action) with this request body:

```json
{
  "name": "Choose a follow-up delay",
  "graph": {
    "nodes": [
      {
        "id": "reply",
        "type": "trigger.positive_reply",
        "position": { "x": 0, "y": 100 },
        "data": { "label": "Positive reply" }
      },
      {
        "id": "company",
        "type": "logic.condition",
        "position": { "x": 300, "y": 100 },
        "data": {
          "label": "Works at Acme?",
          "match": "all",
          "rules": [
            { "field": "lead.company", "operator": "equals", "value": "Acme" }
          ]
        }
      },
      {
        "id": "short-wait",
        "type": "action.wait",
        "position": { "x": 600, "y": 0 },
        "data": { "label": "Wait one day", "days": 1 }
      },
      {
        "id": "long-wait",
        "type": "action.wait",
        "position": { "x": 600, "y": 200 },
        "data": { "label": "Wait three days", "days": 3 }
      }
    ],
    "edges": [
      { "id": "entry", "source": "reply", "target": "company" },
      { "id": "yes", "source": "company", "sourceHandle": "yes", "target": "short-wait" },
      { "id": "no", "source": "company", "sourceHandle": "no", "target": "long-wait" }
    ]
  }
}
```

This example chooses a delay and then ends; connect further actions after the
wait blocks for your workflow. Save accepts unfinished drafts. Publish with
`POST /v1/workflows/{id}/publish` (MCP `manage_workflows`, publish action) to validate all paths and
activate the saved version. The running version is separate from later edits.

Each condition needs at least one connected outcome. An unconnected outcome
ends that run. Paths can contain more conditions or rejoin at a common step;
only the chosen path runs. Loops are not supported.

## Rules and data

Set `match` to `all` for AND or `any` for OR. Each condition accepts 1–20 rules.
The [API reference](/rest-api/reference) exposes the complete schema.

| Fields | Value source |
| --- | --- |
| `lead.first_name`, `lead.last_name`, `lead.email`, `lead.company`, `lead.phone` | Lead fields when the condition runs |
| `inbox.email`, `inbox.provider` | Inbox fields when the condition runs |
| `thread.label_ids` | Current reply-label IDs on the thread |
| `trigger.event`, `trigger.label`, `trigger.labelSource` | The event that started the run |
| `trigger.metadata` | Event metadata, with a dot-separated `path` |
| `step.output` | An earlier step's saved output, with `nodeId` and `path` |

For example, use `field: "step.output"`, `nodeId: "enrich"`,
`path: "phones.length"`, `operator: "greater_than"`, and `value: 0`
to check whether an earlier enrichment found any phone numbers. That step must
precede the condition on every possible incoming path. Array indices also work,
such as `phones.0.number`; paths never execute code.

Text supports `equals`, `not_equals`, `contains`, and `not_contains`.
Comparisons are case-sensitive. Step outputs and metadata also support numeric
`greater_than` and `less_than`; use JSON numbers, not quoted numeric strings.
Equality preserves the value's text, number, or boolean type. Thread labels
support `contains`/`not_contains` with a label UUID.

All fields support `is_set` and `is_not_set` without a comparison value.
Missing/null values, empty text, and empty arrays count as unset; `0` and
`false` count as set. Missing data does not satisfy negative comparisons: use
`is_not_set` explicitly. An existing thread with no labels matches
`not_contains` for any selected label. Inbox-health workflows cannot read lead
or thread fields, and every exit must retain start, wait, and finish recovery.

## Reference trigger and step data

Authored text in Conductor names/prompts, custom agents, follow-ups, Apollo
match inputs, and PlusVibe field values accepts `{{...}}` references:

| Reference | Value |
| --- | --- |
| `{{lead.email}}`, `{{lead.linkedin_url}}` | Current workspace-owned lead fields |
| `{{lead.custom["Account tier"]}}` | A lead custom field |
| `{{inbox.email}}`, `{{inbox.provider}}` | Current inbox fields |
| `{{workflow.name}}`, `{{run.id}}` | Workflow/run identity |
| `{{trigger.metadata.payload.email}}` | A field from a PlusVibe event's signed payload |
| `{{trigger.metadata.lead.linkedin_url}}` | A field from the fetched PlusVibe lead, if present |
| `{{steps["enrich"].person.email}}` | A previous enrichment block's result |
| `{{steps["enrich"].phones[0].number}}` | An array entry in that result |
| `{{steps["enrich"]}}` | The complete result serialized as JSON |

Use the actual field names in the event/provider response. Optional data can
use a scalar fallback, such as `{{steps["enrich"].person.email ?? "Unknown"}}`.
Absent lead/inbox values resolve to empty text; other missing fields fail the
step unless a fallback is provided. Resolved values are never evaluated again.

References support property paths, numeric array indexes, and JSON scalar
fallbacks only. Publish rejects malformed references and step IDs that do not
precede the current block on every incoming path, including one-sided branches
that later rejoin. IDs, credentials, connection targets, and condition rules
are not interpolated; conditions retain the structured fields described above.

## Clay and Floqer workflows

Connect Clay or Floqer in Settings → Integrations before authoring its action.
API and MCP clients can list the connection and searchable catalog with
MCP `read_workflow_connectors`; refresh the catalog with
`manage_workflow_connectors`. Select the action from each tool's input schema. Credentials are entered only in Settings and
are never returned through REST or MCP. Clay lists functions and workflows
exposed to API & CLI; Floqer lists published Shortcuts.

Use `action.clay` or `action.floqer` with the local connection ID and the exact
target returned by discovery. Do not construct `externalTarget` from a display
name: its ID and saved input/output contract are used to detect deleted,
unpublished, or changed provider resources.

```json
{
  "id": "research",
  "type": "action.floqer",
  "position": { "x": 300, "y": 100 },
  "data": {
    "label": "Research company",
    "externalConnectionId": "00000000-0000-0000-0000-000000000000",
    "externalTarget": {
      "id": "shortcut-id-from-catalog",
      "name": "Company research",
      "kind": "shortcut",
      "inputs": [
        { "key": "email", "name": "Email", "type": "string", "required": true }
      ],
      "outputs": ["Company website"],
      "available": true,
      "schemaAvailable": true
    },
    "externalInputs": { "email": "{{lead.email}}" },
    "externalTimeoutMinutes": 1440
  }
}
```

Input values accept the same `{{...}}` references described above. The web
editor suggests conservative mappings for common lead fields and leaves other
fields for review. Non-string provider inputs use JSON text. The timeout can be
1–10,080 minutes; waiting releases worker capacity. Completion exposes
`provider`, `runId`, `status`, `result`, and Floqer's per-field
`fieldStatuses` to later blocks.

A catalog update pauses active workflows and callers when a selected target is
missing or its input/output contract changed. The same contract is checked live
immediately before each new provider run, so a stale catalog cannot start a
deleted or changed routine. An already accepted remote run keeps its saved run
ID and continues collection. Provider rejection, a missing remote run, or a
completed result missing expected output fields pauses the run in the failed
state. Retrying a failed or ambiguous start requires explicit cost
acknowledgement; a timeout resumes polling the saved run without starting
another provider run.

## Apollo enrichment

The existing `action.apollo_enrich_phone` node supports `data.apolloMode` values
`phone` (the default), `person`, and `company`. Connect an Apollo key in Settings
→ Integrations before publishing. A Master API key supports all modes; a
scoped key must permit the selected provider endpoint.

For example, enrich a person using a PlusVibe event's email:

```json
{
  "id": "enrich",
  "type": "action.apollo_enrich_phone",
  "position": { "x": 300, "y": 100 },
  "data": {
    "label": "Enrich person",
    "apolloMode": "person",
    "apolloUseLeadDefaults": false,
    "apolloRevealPersonalEmails": false,
    "apolloInputs": {
      "email": "{{trigger.metadata.payload.email}}"
    }
  }
}
```

`apolloUseLeadDefaults` defaults to true. Nonempty input mappings override the
saved lead values; clearing an override restores the saved value. Person and
phone modes accept `email`, `linkedinUrl`, `personId`, or `firstName` plus
`lastName` and either `companyName` or `companyDomain`. Company mode accepts
`companyDomain`, `companyWebsiteUrl`, or `companyLinkedinUrl`. Inactive inputs
are retained in the draft but ignored for the selected mode.

Outputs include `matched`, `person`, `company`, and `phones`; complete provider
profiles remain available to later references. Personal email reveal is
opt-in. Phone mode polls for results and retains do-not-call flags. Save phone
requires phone mode on every incoming Apollo path. Completed results are
reused on retry; an uncertain paid request is not repeated automatically.

## Positive reply campaign filters

`trigger.positive_reply` defaults to all campaigns in the current workspace.
To restrict it, set `data.positiveReplyAllCampaigns` to `false` and provide
1–100 campaign UUIDs in `data.positiveReplyCampaignIds`. Saving rejects IDs
outside the workflow's workspace; publishing requires `campaign:read`.

Only replies attributed through an owned campaign contact or send history
match a selected-campaign trigger. Unattributed replies do not match, and
untrusted event metadata cannot select another campaign.

## Permissions and run history

Saving requires `workflow:update` and owned resource references. Publishing
requires `workflow:publish`, the permissions needed by every action, and
`lead:read`, `inbox:read`, or `unibox:read` for the subject fields used in rules.
Lead/inbox text references also require the corresponding read permission;
selected-campaign positive-reply triggers require `campaign:read`.
The same checks apply to MCP calls; a key does not gain access by using MCP.

Use `GET /v1/workflows/{id}/runs` (MCP `read_workflows`, runs action) with `workflow:read` to
inspect execution history; pass `runId` to retrieve a particular run. Condition executions include `branch: "yes"` or
`branch: "no"`; other executions have a null branch. Unselected blocks have no
execution rows. Raw node outputs are not returned by this endpoint.

The first decision is saved before the next step runs. Retries reuse it even
if live lead or thread data changes, so one run cannot switch to the other path
halfway through. A skipped action still stops the selected path.

## Trigger request logs

`GET /v1/workflows/{id}/trigger-logs` (MCP `read_workflows`, trigger-logs action) requires
`workflow:read` and returns `{ items, nextCursor }` for the owned workflow's
PlusVibe HTTP attempts from the last 30 days. Pass `limit` (1–50, default 20),
`cursor` (the preceding page's `nextCursor`), and optional `outcome`:
`received`, `accepted`, `rejected`, `sample`, `ignored`, `duplicate`, or `error`.

Items include `id`, `createdAt`, `completedAt`, `workflowVersionId`, `provider`,
`webhookId`, `outcome`, `reason`, `httpStatus`, nullable `runId`, and
`requestFields` (bounded, allowlisted provider event/identity fields).
Samples/rejections appear without creating runs. Duplicate attempts link to the
original run. `received` with no completion means processing has not finished or
was interrupted. Raw payloads, headers, credentials and lead contact details are
excluded. Logging begins on deployment; older requests are not backfilled.

## Conductor workspaces

`action.conductor_start_workspace` creates a cloud workspace and hands off a
prompt using the current workspace's connected Conductor account. Configure
exactly one of `data.conductorProjectId` or `data.conductorRepositoryUrl`, plus
`conductorWorkspaceName` and `conductorPrompt`. Connect the account in the web
app; `GET /v1/workflows/options/conductor-projects` lists its available projects.

`conductorBranch` names an existing base branch. Omit it to use Conductor's
project or repository default. Scheduler Zero passes explicit names to Conductor
without checking branch existence. A missing branch may fail workspace setup
after this action has handed off the prompt. Completion of this action does not
mean the agent finished its work.

`conductorAgent` accepts `claude` (the default), `codex`, or `cursor`. Omit
`conductorModel` and `conductorEffort` to use Conductor's runtime defaults, or
provide explicit values to pin them. Model IDs remain free text; Conductor
validates support for the selected agent. Effort accepts `none`, `low`,
`medium`, `high`, `xhigh`, `max`, or `ultra`, subject to the chosen agent and
model's support.

## Prospeo enrichment

`action.prospeo_enrich_person` enriches the run's current lead using the
workspace key connected under Settings → Integrations → Prospeo. Its data is:

```json
{
  "label": "Prospeo enrichment",
  "prospeoOnlyVerifiedEmail": true,
  "prospeoEnrichMobile": false,
  "prospeoOnlyVerifiedMobile": false
}
```

The durable output contains `matched`, `freeEnrichment`, `person`, `company`,
`email`, `phones`, and the response-header `rateLimit` snapshot. A `NO_MATCH`
provider response becomes `matched: false`, so a later If/Else can branch on it
without failing the run. Only revealed, non-obfuscated contact values appear in
the normalized `email` and `phones` fields. A **Save phone** block may follow
only when mobile enrichment is enabled.

Prospeo charges more when a mobile is found. The action is subscription- and
external-effects-gated, uses a durable request claim to prevent replay after an
unknown POST outcome, and coordinates plan-aware rate limits across workers.
Prospeo currently provides synchronous APIs only, so there is no Prospeo
workflow trigger or webhook lifecycle.

## Custom agent tool access

`action.agent` runs once on a thread and returns `{ text, webAccessAllowed,
toolCalls }`. Use it for research and selected app actions, then reference its
`text` from a later If/Else block. It uses the same `ai_follow_up` feature gate
as thread follow-ups, but has no email cadence or implicit send.

Both `action.agent` and `action.ai_follow_up` accept `data.tools.webAccess`:
`{ "mode": "off" }`, `{ "mode": "auto" }`, or
`{ "mode": "conditional", "condition": "The recipient asks about their company" }`.
Conditional access is evaluated by a model before the built-in web tool is
exposed. Use an upstream If/Else for deterministic field comparisons.

Select owned workspace connectors with `mcpServerIds`, and narrow each server's
workspace permissions with `mcpToolNames: { "<server UUID>": ["TOOL_NAME"] }`.
An explicit empty list grants no tools; omitted maps preserve legacy workspace
allowlists. New app/MCP connections and their OAuth grants are configured through
the builder's Tool access panel. The model cannot add connections or expand its
tool permissions. Connector actions run during generation, independently of
email-draft approval.

## PlusVibe blocks

The graph authoring endpoints accept `trigger.plusvibe`, `trigger.lead_enrolled`,
and `action.plusvibe`. Connection setup and discovery remain browser-only under
Settings → Integrations. Graphs pin a connection UUID and validate provider
resources when published and again at execution.

A PlusVibe trigger requires `plusvibeConnectionId`, `plusvibeWorkspaceId`,
`plusvibeEvents` (such as `ALL_POSITIVE_REPLIES` or
`LEAD_MARKED_AS_INTERESTED`), `plusvibeAllCampaigns`, and
`plusvibeCampaignIds` (nonempty unless all campaigns is selected). Optional
`plusvibeIgnoreOoo` and `plusvibeIgnoreAutomatic` default to true. Publishing
creates the authenticated callback URL and remote webhook automatically.

A PlusVibe action requires `plusvibeConnectionId`, `plusvibeWorkspaceId`, and
`plusvibeCampaignId`. `plusvibeOperation` is `add_lead` (default), `update_lead`,
or `complete_lead`. `plusvibeVariables` holds additional string fields or a
`label` key. Add-lead options are `plusvibeSkipIfInWorkspace` (default false) and
`plusvibeSkipIfInActiveCampaign` (default true). Lead fields are copied from the
run's local lead. Unknown provider write outcomes require review before a new run.

PlusVibe events and manually enrolled leads can use Apollo or Prospeo enrichment, Save
phone, Add to dialer list, PlusVibe actions, Wait, and Conductor actions. They
cannot use thread actions or conditions that require a thread/inbox. Build
`PlusVibe event → Apollo enrichment → Save phone → Add to dialer list` for
reply-to-dialer automation, and `Lead enrolled → PlusVibe action` for the reverse
flow from selected dialer leads.

### GetLeads blocks

Connect the provider in Settings → Integrations first. `trigger.getleads` needs
`getleadsConnectionId`, `getleadsEmailPath`, and `getleadsEventIdPath`; configure
the generated callback URL as the visitor destination in GetLeads. JSON paths
must match an actual delivery. This is a lead subject, so thread actions are
unavailable.

`action.getleads` uses the same pinned connection ID and `getleadsMode`:
`lead_email`, `lead_linkedin`, `lead_person`, `lead_phone`, or `request`. The last
mode also requires `getleadsRequest: { operation, input }` from the published
graph schema. Billable requests have durable claims and uncertain outcomes are
not automatically retried. Results appear under the step's `result` output;
If/Else can inspect paths such as `result.results.0.success`. Connection,
lead-import, and integration permissions are checked before publication.

## Domain replacement preset

Create with `{"name":"Client domain monitoring","recipe":"domain_rotation"}`.
This creates a draft with `trigger.domain_health → action.domain_rotation`.
Save the trigger's `reserveTagId` (an inbox tag owned by this workspace), then
publish. Create one workflow per client workspace.

The trigger accepts `belowAveragePercent` (30–40, default 35), `windowDays`
(7–30, default 14), `minContacts` (100–10000, default 100), `minWarmupDays`
(14–90, default 21), `minWarmupScore` (70–100, default 80), and `rotationMode`
(`auto` or `flag_only`, default `auto`). The action reads this published trigger
configuration and needs no separate settings.

Every six hours, S0 compares domain reply rates with the arithmetic mean of
sufficiently sampled domains in that workspace. Replies use equal 48-hour
observation windows and exclude known automatic replies. The relative threshold
is inclusive: 6.5% is 35% below a 10% average.

Connect and tag reserve Google Workspace inboxes before publishing. All inboxes
on a reserve domain must carry the reserve tag and remain outside campaigns.
The workflow maintains their S0 warmup; it does not buy or create mailboxes.
Only individually warmed inboxes with recent confirmed placement can be used.
Automatic swaps preserve old inbox connections/history, pause their campaign
sending, and add replacement senders to the affected campaigns. Existing
campaign fallback settings govern follow-ups. No ready reserve means a recorded
flag with no sender change. Inspect results in Workflows → Runs.

Publishing requires `inbox:read,update,warmup_manage`, `analytics:read`, and
`campaign:read,update` in addition to workflow permissions and an active
subscription. This trigger runs only from scheduled measurements; the manual
workflow test endpoint rejects it.

## Inbox replacement preset

Create with `{"name":"Client inbox monitoring","recipe":"inbox_rotation"}`.
Save the trigger's owned `reserveTagId`, then publish. This uses the graph
`trigger.inbox_reply_rate → action.domain_rotation`, with `analysisLevel: "inbox"`
on the trigger. `analysisLevel` must match the trigger: `inbox` for
`trigger.inbox_reply_rate`; `domain` (the default) for `trigger.domain_health`.
All other settings and permissions match the domain preset.

Every six hours, each active sending inbox on a custom domain is compared with
the unweighted average of this workspace's sufficiently sampled inboxes, even
when they share a domain. The default threshold is 35% below that average
(configurable 30–40%), with at least 100 first-contact recipients per inbox and
48-hour reply windows. Low-volume, paused, and reserve inboxes stay outside the
baseline. At most one flagged run is admitted per published version/inbox/UTC day.

A swap pauses only the flagged inbox and adds one ready reserve on a different
domain to its affected campaigns. Healthy siblings stay active. Tagged reserve
inboxes must stay outside campaigns and active recoveries, but other inboxes
on their domain may already send. Only the consumed inbox loses its reserve tag;
remaining tagged inboxes keep warming and can serve future replacements. Inboxes
must already be connected and individually warm; this preset does not provision
mailboxes. No ready reserve records a flag without changing senders.

Inspect individual measurements through **Inbox report** in the workflow editor
and replacement outcomes in **Runs**. The existing `domainRotation` result field
also carries inbox outcomes, including `analysisLevel`, `inboxId`, `emailAddress`,
and, after a swap, `replacementEmailAddress`. Both presets share retry protection,
subscription checks, and campaign follow-up behavior described above.

## Domain blacklist monitoring and workflow enrollment

Create a draft with `{"name":"Domain blacklist monitoring","recipe":"domain_blacklist"}`.
Connect MXToolbox in Settings → Integrations, then select the connection and an
active published destination before publishing. The recipe checks all active
sending domains daily; the trigger supports `domainScope: "selected"`, a
`domains` selection, and `intervalHours` from 1 to 168.

`action.mxtoolbox` takes `mxtoolboxConnectionId`, `mxtoolboxCommand` (`blacklist`,
`mx`, `spf`, `dmarc`, `dns`, `dkim`), and `mxtoolboxDomain`, a typed binding.
DKIM requires `dkimSelector`. The policy is `listedCount > blacklistThreshold`
OR the `blacklistIds` selection matches (`blacklistMatch: "any" | "all"`).
Select IDs returned by `GET /workflows/integrations/mxtoolbox`; invented IDs
cannot pass publication. Domain lists do not describe outgoing IP reputation.
Incomplete results pause the workflow instead of classifying the domain as clean.

A destination starts with `trigger.workflow_enrolled` and `workflowInputSchema`:

```json
[{"name":"domain","type":"domain","required":true}]
```

Find eligible destinations and their schemas with
`GET /workflows/options/enrollment-targets`. Add `action.enroll_in_workflow`
with `targetWorkflowId` and typed `workflowInputMappings`:

```json
{"domain":{"source":"step","nodeId":"health","path":"domain"}}
```

Bindings also support `{"source":"input","path":"domain"}` and
`{"source":"literal","value":"example.com"}`. Types are preserved; references
must point to guaranteed preceding blocks. Enrollment admits one child per
parent run/block and returns its run/version IDs. The child runs independently.
`action.workflow_output` declares `workflowOutputSchema` and
`workflowOutputMappings` for the terminal result of a branch.

An unavailable or incompatible destination pauses its callers and raises
browser/mobile/email alerts. Inspect `GET /workflows/{id}/alerts`, repair and
reactivate the workflow, then `POST /workflows/{id}/runs/{runId}/resume`.
Resuming needs `workflow:run,publish` and the workflow's action permissions;
uncertain/incomplete MXToolbox retries additionally need
`acknowledgeLookupCost: true`. Completed blocks are reused, and the run retains
its original graph version. Stop an obsolete run with the existing
`POST /workflows/runs/{runId}/stop` endpoint.

### Human approval and agent questions

Use `action.human_approval` to wait for a human before the next block:

```json
{
  "id": "review",
  "type": "action.human_approval",
  "position": { "x": 0, "y": 200 },
  "data": {
    "label": "Review proposal",
    "question": "Approve this proposal? {{steps[\"agent\"].text}}",
    "assigneeIds": ["00000000-0000-4000-8000-000000000001"],
    "timeoutDays": 7
  }
}
```

The referenced agent must precede this block on every path. Choose current
workspace user IDs; `timeoutDays` is 1–30. Publishing requires task read/create
permission. Approval continues with `{ approved: true, response, taskId }`;
rejection or expiry stops the selected path. Requests appear under Tasks →
Workflow requests. An assigned human with task update permission must answer;
API keys and MCP clients cannot approve on their behalf.

For `action.agent`, set `data.humanInput` to `{ "assigneeIds": ["…"],
"timeoutDays": 7 }` to expose `request_user_input`. The agent can request an
approval, a free-text answer, or one choice. The conversation pauses durably,
then resumes with the human's response as the tool result. Omit `humanInput`
to keep the existing unattended agent behavior. This setting does not enable
questions on `action.ai_follow_up` or change its email-draft approvals.

