Triggo Documentation
Agent Integration

Publishing Workflows as Actions

Turn a workflow into a callable action that an AI agent can invoke via REST or MCP.

Publishing Workflows as Actions

Triggo exposes two surfaces to outside callers: a REST API at /api/v1/runtime/ and an MCP server at POST /mcp. Both show the same resource — a published action — and both refuse to see anything that is not published. This page covers the round trip from "I have a workflow that works on the canvas" to "my agent can call it by slug with a Bearer token."

Callable vs event-driven workflows

Every workflow in Triggo has a type that decides how it starts. The type is stored in the workflows table as one of two values:

  • event_driven — started by the platform. A webhook fires, a schedule ticks, a connector trigger emits an event, and the workflow runs with whatever payload the platform delivered. Event-driven workflows are not directly invokable by an agent. They have exactly one trigger node; publishing one via the runtime API is rejected.
  • callable — started by an explicit agent call. This is the shape you publish as an action. A callable workflow must have exactly one action_input node (which defines the input schema the caller fills in) and at least one return_output node (which defines what the call returns). The validator enforces both rules: "Callable workflow must have exactly 1 Action Input node" / "Callable workflow must have at least 1 Return Output node".

The type is derived at creation time from the shape of the definition: if the canvas contains an Action Input placeholder and a Return Output placeholder, the workflow is callable; if it contains a trigger node, it is event-driven. Today these are placeholder node types (action_input, return_output) — the system-nodes-overhaul spec is redesigning them into first-class Manual Trigger and Return nodes, but the behavior described here matches current code.

The callable workflow shape

A callable workflow is three things stitched together:

  1. An Action Input node at the start. Its data.properties array (name, type, required) becomes the action's public input schema. At deploy time the schema is derived by deriveInputSchemaFromDefinition() into a JSON-Schema-like object: { type: "object", properties: { <name>: { type: <type> } }, required: [...] }.
  2. One or more Return Output nodes at the end. Their data.properties become the output schema via deriveOutputSchemaFromDefinition(){ type: "object", properties: {... } }, or null if no output properties are defined.
  3. Any nodes in between doing the actual work — connector actions, conditions, transforms, code nodes, loops, LLM calls. Field mapping {{action_input.field}} pulls the caller's input into downstream nodes.

The input schema is the action's public contract. Changing it is a breaking change for every caller. Adding a new non-required field is safe; renaming an existing field, making a field required, or changing its type is not. Callers validate against this schema before the run is even queued (see "Errors" below).

Publishing

On the canvas, the primary toolbar button for a callable workflow is Activate for the first deploy and Publish Action for every subsequent one (the secondary Run manually button lives next to it). See Activation and Versioning for the full trigger-aware primary-action state machine (event-driven workflows follow the same transaction but the toolbar label is Activate/Stop instead). Clicking the primary button does three things in one transaction:

  1. Validates the current draft against the callable rules (1 action_input, >=1 return_output) or the event-driven rules (exactly 1 trigger), plus the node/edge complexity limits.
  2. Archives the previous deployed version (status flips from deployed to archived) and promotes the draft to a new workflowVersions row with status deployed.
  3. For callable workflows, derives and freezes the input and output schemas onto the new version.

Once a workflow has a deployed version you can create the public action. The action carries a slug — a stable, workspace-unique identifier the caller uses instead of the internal UUID — and links to the current deployed version via actionReleases. The slug is chosen by the publisher, not derived from the workflow name: it is passed explicitly to actionService.create(), validated for workspace uniqueness, and cannot be reused by another workflow (one action per workflow is a v1 invariant).

After publish, the action is visible at two surfaces:

  • REST: GET /api/v1/runtime/actions and GET /api/v1/runtime/actions/:slug.
  • MCP: list_actions (returns the same list) and get_action (returns the same detail).

Drafts and paused actions are invisible to both surfaces — list filters to status = "active" by default, and a run attempt against a non-active action returns ACTION_UNAVAILABLE (409).

Invoking via REST

POST /api/v1/runtime/actions/:slug/run with a Bearer API key that carries the actions:run scope.

Request body:

{
  "input": { "<field>": "<value>" },
  "dry_run": false
}
  • input — an object matching the action's inputSchema. Non-object values are coerced to {}.
  • dry_run — optional boolean. When true, the run is recorded with source: "dry_run" and immediately marked succeeded; no BullMQ execution job is enqueued, no connector side effects happen, and user rate-limit / billing quota checks are skipped.

Response (HTTP 202 Accepted, from executionService.createActionRun):

{
  "run_id": "6f3e...-uuid",
  "action_slug": "normalize-lead",
  "action_release_version": 3,
  "source": "action",
  "status": "accepted",
  "input": { "...": "..." },
  "output": null,
  "error": null,
  "steps": [],
  "approval": null,
  "dry_run": false,
  "started_at": null,
  "completed_at": null,
  "duration_ms": null,
  "created_at": "2026-04-12T10:15:30.000Z"
}

Full curl example — an action named normalize-lead that takes a raw CRM record and returns a cleaned version:

curl -sS -X POST https://api.triggo.dev/api/v1/runtime/actions/normalize-lead/run \
  -H "Authorization: Bearer trg_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "email": " Ivan@Example.COM ",
      "phone": "+7 (912) 345-67-89",
      "source": "webform"
    },
    "dry_run": false
  }'

You get back 202 with a run_id. Fetch the final output with GET /api/v1/runtime/runs/:runId once the run completes (see "Polling run status" below).

Invoking via MCP

The MCP run_action tool accepts { slug, input, dry_run? }. A minimal JSON-RPC envelope:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "run_action",
    "arguments": {
      "slug": "normalize-lead",
      "input": {
        "email": " Ivan@Example.COM ",
        "phone": "+7 (912) 345-67-89",
        "source": "webform"
      },
      "dry_run": false
    }
  }
}

The response is an MCP tool result whose content[0].text is the same JSON object as the REST 202 body — run_id, status: "accepted", action_release_version, and so on. On error the envelope flips isError: true and text carries { error, code, details? }. The MCP transport itself stays 200 OK; tool errors are expressed inside the envelope, not via HTTP status.

Sync vs async

POST /run is async. The endpoint validates the input against the action's schema, checks the per-user execution rate limit (100/hour) and billing quota, inserts a row into runs with status: "accepted", enqueues a BullMQ execution job on Redis, and immediately returns 202 — it does not wait for the job to complete.

The returned run_id is the handle you poll. There is no synchronous cap to quote because the endpoint never blocks on execution; the 30-second per-step and 300-second per-pipeline timeouts govern the background run itself, not the initial HTTP call.

One exception: when the action's approval_policy = "always", the initial status is waiting_for_approval instead of accepted, an approval_requests row is created with a 1-hour expiry, and no execution job is enqueued until the approval is decided via POST /runs/:runId/approve or the MCP approve_run tool.

Polling run status

Fetch the run by id from either surface:

  • REST: GET /api/v1/runtime/runs/:runId (scope runs:read).
  • MCP: get_run_status with { run_id } (scope runs:read).

The response carries the live status (accepted, running, waiting_for_approval, succeeded, failed, cancelled, timed_out), the structured output (populated from the Return Output node when the run succeeds), and a steps timeline derived from journal events. See Debugging Runs for the full step timeline shape and how to interpret each status.

Errors

Errors at the REST surface use the code map in:

HTTPCodeWhen
400INPUT_VALIDATION_FAILEDinput does not match the action's schema. Response includes details: [...] with the field-level errors.
401UNAUTHORIZEDBearer token missing, malformed, revoked, or expired.
403FORBIDDENValid key, but does not carry actions:run.
404ACTION_NOT_FOUNDSlug does not exist in this workspace. Drafts look identical to deleted actions from the outside.
409ACTION_UNAVAILABLEAction exists but its status is not active — usually paused or archived, or it has no current release yet.
429RATE_LIMIT_EXCEEDEDEither the per-key sliding window (tier-dependent) or the per-user 100-runs-per-hour cap. Response includes a retry-after header in seconds; see Rate Limits.
500anything elseInternal workflow failure during run creation — rare, the run itself fails later and surfaces on GET /runs/:runId.

Over MCP the same codes appear inside content[0].text with isError: true and the transport stays 200 OK.

On this page