Automation API

REST endpoints for creating, managing, and executing automation flows, runs, scripts, and webhooks.

#Automation API

The automation system runs server-side flows in response to events, webhooks, schedules, or manual triggers. Flows consist of a trigger, a sequence of operations, and execution options.

All automation endpoints are mounted at /api/v1/automate/. The bare /api/automate/ alias is deprecated (ADR 0005).

#Permissions

Action Required for
read List flows, runs, scripts, operations, email log, quotas
create Create flows, scripts
update Update flows, scripts, publish, set status
delete Delete flows, runs, scripts
write Cancel runs, cleanup stale runs
manage Rollback versions, update quotas
execute Execute flows, test flows, test scripts

#Flow object

{
	"id": "550e8400-e29b-41d4-a716-446655440000",
	"projectId": "proj_abc",
	"name": "New Order Notification",
	"description": "Send email when a new order is created",
	"icon": "bolt",
	"color": "#6750a4",
	"status": "active",
	"triggerType": "event_action",
	"triggerConfig": { "events": ["record.create"] },
	"operations": [
		{
			"key": "notify",
			"type": "send_email",
			"config": {
				"to": "{{$trigger.body.email}}",
				"subject": "Order Confirmed",
				"body": "Thanks for your order!"
			}
		}
	],
	"options": {
		"timeout_ms": 30000,
		"max_retries": 0,
		"concurrency": "sequential",
		"error_handling": "stop_on_first",
		"log_level": "full"
	},
	"collections": ["orders"],
	"createdBy": "usr_01HXK5M9ABCDEF",
	"runAs": "triggering_user",
	"currentVersion": 3,
	"publishedVersion": 2,
	"lastRunAt": "2026-06-20T10:30:00.000Z",
	"runCount": 42,
	"errorCount": 1,
	"createdAt": "2026-06-01T00:00:00.000Z",
	"updatedAt": "2026-06-20T10:30:00.000Z"
}

#Flow statuses

Status Description
draft Initial state. Use testFlow() to dry-run. Triggers do not fire.
active Live. Triggers fire against the published version.
inactive Paused. Triggers are ignored. Reactivate by setting status to active.

#Flow CRUD

#List flows

GET /api/v1/automate/flows

Returns all flows in the current project.

#HTTP

GET /api/v1/automate/flows
Authorization: Bearer sk-your-api-key

#SDK

import { EmuView } from '@emuview/sdk';

const sdk = new EmuView({
	url: 'https://your-api.example.com',
	auth: { apiKey: 'sk-your-api-key' }
});

const { data: flows } = await sdk.automate.listFlows();

#Create flow

POST /api/v1/automate/flows

Create a new flow. Flows always start in draft status.

#Parameters

Parameter Type Required Description
name string Yes Flow name (1–200 characters)
description string No Human-readable description
icon string No Material icon name (default: bolt)
color string No Hex colour (default: #6750a4)
triggerType string Yes One of the trigger types
triggerConfig object Yes Trigger-specific configuration
operations object[] Yes Array of operation nodes
collections string[] No Collections in scope (default: ["*"])
runAs string No Execution identity (default: triggering_user)
options object No Timeout, retries, concurrency, error handling

#HTTP

POST /api/v1/automate/flows
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "name": "Welcome Email",
  "triggerType": "event_action",
  "triggerConfig": { "events": ["record.create"] },
  "collections": ["users"],
  "operations": [
    {
      "key": "send_welcome",
      "type": "send_email",
      "config": {
        "to": "{{$trigger.body.email}}",
        "subject": "Welcome to our app!",
        "body": "Hello {{$trigger.body.name}}, welcome aboard!"
      }
    }
  ]
}

#SDK

const { data: flow } = await sdk.automate.createFlow({
	name: 'Welcome Email',
	trigger_type: 'event_action',
	trigger_config: { events: ['record.create'] },
	collections: ['users'],
	operations: [
		{
			key: 'send_welcome',
			type: 'send_email',
			config: {
				to: '{{$trigger.body.email}}',
				subject: 'Welcome to our app!',
				body: 'Hello {{$trigger.body.name}}, welcome aboard!'
			}
		}
	]
});

#Response

201 Created — returns the full flow object in draft status.

#Get flow

GET /api/v1/automate/flows/:flowId

Returns flow detail including publishedOperations and webhookEndpoint (if applicable).

#Update flow

PATCH /api/v1/automate/flows/:flowId

Update the draft version. You can update name, operations, triggerConfig, options, collections, and runAs.

#HTTP

PATCH /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "operations": [
    {
      "key": "send_welcome",
      "type": "send_email",
      "config": {
        "to": "{{$trigger.body.email}}",
        "subject": "Welcome!",
        "body": "Updated welcome message."
      }
    }
  ]
}

#SDK

await sdk.automate.updateFlow('550e8400-e29b-41d4-a716-446655440000', {
	operations: [
		{
			key: 'send_welcome',
			type: 'send_email',
			config: {
				to: '{{$trigger.body.email}}',
				subject: 'Welcome!',
				body: 'Updated welcome message.'
			}
		}
	]
});

#Delete flow

DELETE /api/v1/automate/flows/:flowId

Delete a flow and all associated versions, runs, step logs, and webhooks.

#Duplicate flow

POST /api/v1/automate/flows/:flowId/duplicate

Create a copy of a flow in draft status.

POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/duplicate
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{ "name": "Welcome Email (Copy)" }

#Re-importing an exported flow

GET /api/v1/automate/flows/:flowId/export emits a portable document that carries the flow's identity — its slug, which is unique within the Project. Sending that document straight back to POST /api/v1/automate/flows/import therefore asks for a second flow at a slug the Project already uses, and is refused with 409. Changing name alone does not help: the name is not the identity.

Choose the one you meant:

  • Update the flow you exportedPOST /api/v1/automate/flows/import?flowId=<id>. The document replaces that flow's draft; the flow keeps its id, version history, run counters, and webhook token.
  • Make a copy — omit slug from the imported body, and a fresh one is derived from the new name and numbered past whatever the Project already holds. POST /api/v1/automate/flows/:flowId/duplicate does the same thing in a single call.

Importing into a different Project needs neither adjustment — a slug is unique only within its own Project, so the exported one is free there.

#Credentials in a flow's config are masked

A step's config can hold a credential — a webhook_dispatch signing secret, an http_request bearer token or basic password, an Authorization header on any of them — and so can a webhook trigger's secret. Every read of a flow replaces those values with ••••••••:

  • GET /api/v1/automate/flows
  • GET /api/v1/automate/flows/:flowId, including its publishedOperations
  • GET /api/v1/automate/flows/:flowId/versions/:version
  • GET /api/v1/automate/runs/:runId, where a step's recorded input is its resolved config

A caller holding system/automate:update sees the real values on the first three, because a principal who can edit a flow can already point it at a URL of their own and read the credential off the delivery. system/automate:read alone never does — the same rule webhookEndpoint.token has always followed.

A header name survives the mask and its value does not: a header appearing is a change you should be able to see, and a name is not a credential.

GET /api/v1/automate/flows/:flowId/export masks for everyone, update grant or not. The document is portable — it is what you paste into a Blueprint and what the install ledger canonicalises — so it carries no credential at all.

Writing one back. A masked value means unchanged. POST /api/v1/automate/flows, PATCH /api/v1/automate/flows/:flowId and POST /api/v1/automate/flows/import all restore •••••••• from the value already stored on that flow, matched by step key, so a read-modify-write round trip does not overwrite a credential with the mask. When there is nothing stored to restore — a new flow, a new step, an exported document imported somewhere else — the key is dropped instead, so the flow lands unsigned rather than signing with the mask. Send a real value to set or rotate one.

#Set flow status

PATCH /api/v1/automate/flows/:flowId/status

Change the flow status. Setting to active requires a published version.

PATCH /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/status
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{ "status": "active" }

#Versioning

Every publish creates an immutable snapshot. Only the published version runs in production.

Method Path Permission Description
POST /flows/:flowId/publish update Publish draft as immutable version
GET /flows/:flowId/versions read List versions (newest first)
GET /flows/:flowId/versions/:ver read Get version snapshot
POST /flows/:flowId/rollback/:ver manage Rollback to version (overwrites draft and published)

#Publish

POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/publish
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{ "changeSummary": "Added retry logic to email step" }

#SDK

const result = await sdk.automate.publishFlow(
	'550e8400-e29b-41d4-a716-446655440000',
	'Added retry logic to email step'
);
console.log(`Published version ${result.data.version}`);

#Execution

#Execute flow

POST /api/v1/automate/flows/:flowId/execute

Manually trigger a flow. Runs asynchronously — returns immediately with a runId.

#HTTP

POST /api/v1/automate/flows/550e8400-e29b-41d4-a716-446655440000/execute
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "customer_id": "cust_123",
  "action": "generate_report"
}

#SDK

const { data: result } = await sdk.automate.executeFlow('550e8400-e29b-41d4-a716-446655440000', {
	customer_id: 'cust_123',
	action: 'generate_report'
});
console.log(`Run started: ${result.runId}`);

#Response

202 Accepted

{
	"data": {
		"runId": "run_a1b2c3d4e5f6",
		"flowId": "550e8400-e29b-41d4-a716-446655440000",
		"status": "running",
		"startedAt": "2026-06-30T01:30:00.000Z"
	}
}

#Test flow

POST /api/v1/automate/flows/:flowId/test

Dry-run the flow synchronously using the draft operations. Returns a step-by-step trace.

#Response

200 OK

{
	"data": {
		"runId": "test-a1b2c3d4",
		"status": "completed",
		"dryRun": true,
		"steps": [
			{
				"key": "send_welcome",
				"type": "send_email",
				"status": "completed",
				"output": { "messageId": "msg_test_001" },
				"durationMs": 12
			}
		],
		"logs": []
	}
}

#Runs

Method Path Permission Description
GET /runs?page=1&limit=25&status=failed&flow_id=abc read List all runs (paginated, filterable)
GET /flows/:flowId/runs read List runs for a specific flow
GET /runs/:runId read Run detail with stepLogs array
DELETE /runs/:runId delete Delete run and step logs
POST /runs/:runId/cancel write Cancel a stuck run (pending/running)
POST /runs/cleanup-stale write Reap orphaned runs stuck >2 min

#Run statuses

Status Description
pending Queued, waiting to execute
running Currently executing
completed Finished without errors
failed One or more steps failed
cancelled Cancelled by user or system
timed_out Exceeded timeout_ms

#Step statuses

pending, running, completed, failed, skipped

#Cancel a run

POST /api/v1/automate/runs/:runId/cancel

Cancels a run that is stuck in pending or running state. The run is marked as cancelled and frees up a concurrency slot. Only works on runs that haven't reached a terminal status.

#HTTP

POST /api/v1/automate/runs/run_a1b2c3d4e5f6/cancel
Authorization: Bearer sk-your-api-key

#SDK

await sdk.automate.cancelRun('run_a1b2c3d4e5f6');

#Response

200 OK

{ "success": true }

Returns 404 if the run was not found or already in a terminal status.

#Cleanup stale runs

POST /api/v1/automate/runs/cleanup-stale

Reaps orphaned runs stuck in pending or running state. Any run active longer than 2 minutes is marked as failed with an "orphaned" error. This also runs automatically before each flow execution and on the hourly cron trigger, but you can call it manually to force cleanup.

#HTTP

POST /api/v1/automate/runs/cleanup-stale
Authorization: Bearer sk-your-api-key

#SDK

const { reaped } = await sdk.automate.cleanupStaleRuns();
if (reaped > 0) {
	console.log(`Cleaned up ${reaped} orphaned runs`);
}

#Response

200 OK

{ "success": true, "reaped": 3 }
Note

Stale run cleanup runs automatically in three scenarios:

  1. Before each flow execution — prevents orphaned runs from blocking the concurrency limit.
  2. Hourly cron trigger — sweeps all projects on the 0 * * * * schedule. This depends on the instance's maintenance tick actually running; if it has stalled, only scenarios 1 and 3 apply.
  3. Manual API call — use this endpoint for on-demand cleanup.

#Trigger types

Type Blocking? Config
event_action No (waitUntil) { events: ["record.create", "record.update", "record.delete"] }
event_filter Yes (can abort/modify data) { events: ["record.create"] }
webhook No (202) { require_auth?, allowed_methods?, ip_allowlist?, secret? }
schedule No (DO alarm) { cron: "0 0 * * *", timezone?: "Australia/Sydney" }
manual No (202) { manual_type, collections?, selection?, input_fields?, confirm? }
api_endpoint No (202) { method?, require_auth? }
another_flow Depends on parent {} (data passed from parent trigger_flow operation)
app_event No (waitUntil) { events: ["user.login", "file.upload"] }
Note

Schedule cron expressions are evaluated in the trigger's timezone (an IANA name such as Australia/Sydney), including daylight-saving transitions. When timezone is omitted, schedules are evaluated in UTC. An invalid timezone is rejected with a 422 when the flow is activated.


#Webhooks (public)

Method Path Auth Description
POST /webhook/:token None (unless require_auth) Trigger flow from external service
GET /webhook/:token None (unless require_auth) Trigger flow with query params as data

#Response

202 Accepted

{ "success": true, "runId": "run_a1b2c3d4", "message": "Flow triggered." }

When require_auth is enabled, pass the webhook secret in the Authorization header:

POST /api/v1/automate/webhook/tok_a1b2c3d4
Authorization: Bearer webhook-secret-value
Content-Type: application/json

{ "order_id": "ord_12345" }

#Flow run_as

The run_as field controls the identity used when a flow executes:

Value Behaviour
triggering_user (default) Runs as the user who triggered it. Webhooks fall back to the least-privilege webhook role; scheduled runs fall back to the flow owner
flow_owner Runs as the flow's creator, regardless of who triggered it
role:<roleId> Runs as the triggering identity with the specified role's permissions

Use GET /api/v1/automate/assignable-roles to list roles you can delegate.

New flows always start as triggering_user — the create endpoint does not accept run_as; change it afterwards via PATCH /api/v1/automate/flows/:flowId. The flow settings UI offers only triggering_user and flow_owner; role:<roleId> values are set via the API/SDK.

Important

Setting run_as to anything other than triggering_user requires manage on system/automate, and the delegated identity's permissions must be a subset of your own — otherwise the update returns 403 forbidden. That includes flow_owner when the owner's role outranks yours.


#Template variables

Operation config values support {{expression}} syntax, resolved at runtime:

Expression Value
{{$trigger.body.fieldName}} Field from trigger payload
{{$trigger.event}} Event name (e.g., record.create)
{{$trigger.collection}} Collection name
{{$steps.stepKey.field}} Output from a previous step
{{$user.id}} Current user ID
{{$user.email}} Current user email
{{$flow.id}} Flow ID
{{$run.id}} Current run ID
{{$now}} ISO 8601 timestamp
{{= expression }} Evaluate a JavaScript expression

#Operations catalog

GET /api/v1/automate/operations returns all registered operations with type, name, category, and form fields.

Category Operations
Data crud_action, crud_create, crud_read, crud_update, crud_delete, crud_query, build_search_index
Logic condition, switch, loop, stop, throw_error, set_variable, trigger_flow
Transform transform, json_parse, text_format, math, datetime, log, delay
AI ai_generate, ai_classify, ai_extract, ai_summarize, ai_translate, ai_review, ai_image
Files file_read, file_upload, file_transform, s3_action
Network http_request, graphql_request, webhook_dispatch
Script run_script, run_expression
Communication send_email, send_notification
System system_archival, system_limit_check, system_monthly_summary (system flows only)

Use GET /api/v1/automate/operations/:type for the full config schema of any operation.


#Scripts CRUD

Method Path Permission Description
GET /scripts read List saved scripts
POST /scripts create Create script
PATCH /scripts/:scriptId update Update script
DELETE /scripts/:scriptId delete Delete script
POST /scripts/:scriptId/test execute Test script with mock data

#Quotas

GET /api/v1/automate/quotas returns project quotas and current usage.

Default quotas:

Limit Default
Max flows 50
Max concurrent runs 5
Max execution time 30,000 ms
Max runs per hour 500
Max AI tokens per day 50,000
Max emails per day 200
Max script CPU time 5,000 ms

Admins can update quotas with PATCH /api/v1/automate/quotas (requires manage permission).