This group is for a developer automating multi-step work in a local Relay instance. It covers workflows and the way they run, resume, and stop; blueprints that turn a template into a workflow; schedules that fire tasks on a cron; the notifications that carry approvals and alerts; the permission rules that govern what agents may do without asking; and the built-in Operator Workshop run lifecycle. Read this if you are launching workflow runs, scripting scheduled tasks, polling for pending approvals, managing an agent’s permission allow-list, or integrating the local workshop experience.
Stability
app-internal
These routes power the Relay web app. They are documented so you can build against a local instance, but their request and response shapes are tied to the app’s own screens and may change between releases. Branch on HTTP status codes rather than on the internal structure of an error body, and treat every response field as additive.
Local Access Model
Relay API routes run inside the local Relay app. Use http://127.0.0.1:<port> in examples, where <port> is the port your instance is bound to (3000 by default).
Do not expose these routes on a public network without your own access controls. These routes read and write the local SQLite database, run agent work in the background, and read and write blueprint files on disk.
Conventions For This Group
These behaviors hold across the routes below, so they are stated once here rather than repeated per endpoint:
Workflow status vocabulary. A workflow’s top-level status is one of draft, active, paused, completed, failed. A run in progress is active, not “running”. paused covers two cases: a delay step waiting for its time (the workflow carries a resumeAt) and a step waiting for human input. There is no separate stopped or cancelled status; a stopped workflow becomes failed.
Background execution.execute, resume, and step retry return 202 and run the work in the background. A 202 means the run was claimed and dispatched, not that it finished or even that it will succeed. Poll the status route for the outcome. The stop route is the exception: it acts synchronously and returns 200.
Timestamps.createdAt, updatedAt, and similar fields serialize to ISO date strings on the wire.
Malformed request bodies. Most write routes read the JSON body without a guard. A malformed or empty body throws before validation and surfaces as an unhandled 500, not a structured 400. Send well-formed JSON with Content-Type: application/json.
Validation. These routes validate by hand or with plain validator functions rather than a shared schema. Error bodies are { "error": "<message>" } with the specific message noted per route.
status is one of draft, active, paused, completed, failed. definition is a raw JSON string. liveTaskCount counts running and queued tasks.
Errors: none returned explicitly. A database failure propagates unhandled.
Side effects: reads only.
POST /api/workflows
Creates a workflow in draft status from a name and a definition.
Request body (JSON):
Field
Type
Required
Notes
name
string
yes
Non-empty.
definition
object
yes
A workflow definition with a pattern and at least one step.
projectId
string | null
no
Must resolve to an existing project when non-null.
The pattern is one of sequence, planner-executor, checkpoint, loop, parallel, swarm, each with its own step-shape rules.
Response201: the created workflow row (status is draft, runNumber is 0).
Errors:
400 when name is missing: { "error": "Name is required" }.
400 when the definition is invalid: { "error": "Definition must include pattern and at least one step" }, or a specific validation message.
400 when a step’s runtime and profile are incompatible: { "error": "<message>" }.
404 when projectId does not resolve: { "error": "Project not found: <id>" }.
Side effects: inserts one workflow row. It does not create tasks or start a run.
PATCH /api/workflows/{id}
Edits a workflow’s name, definition, project, or success criteria, or pauses it. Editing is allowed only for draft, completed, or failed workflows.
Request body (JSON):
Field
Type
Notes
name
string
Edit mode. Non-empty.
definition
object
Edit mode. Re-validated.
projectId
string | null
Edit mode. A non-null id must resolve; null clears the link; omission leaves it unchanged.
successCriteria
array
Edit mode. Replaces the workflow’s run criteria.
status
string
Transition mode. Only paused is a real transition.
Response200: in edit mode, the updated workflow row; in pause mode, { "id": "string", "status": "paused" }.
Errors:
404 when not found: { "error": "Workflow not found" }.
404 when projectId does not resolve: { "error": "Project not found: <id>" }.
409 when editing an active or paused workflow: { "error": "Cannot edit active or paused workflows" }.
400 when nothing to change: { "error": "status, name, projectId, definition, or successCriteria is required" }.
409 when pausing a non-active workflow: { "error": "Can only pause an active workflow" }.
400 when asked to set active: { "error": "Use POST /api/workflows/[id]/execute to resume a workflow" }.
400 on an unsupported transition: { "error": "Invalid status transition: <status>" }.
Side effects: updates the workflow. Editing a completed or failed workflow resets it to draft and discards its execution state. Pausing flips the status but does not cancel running tasks.
DELETE /api/workflows/{id}
Deletes a workflow and cascade-deletes its tasks, documents, logs, and usage records.
Request: none.
Response200: { "deleted": true }.
Errors:
404 when not found: { "error": "Workflow not found" }.
409 when the workflow is active with live tasks: { "error": "Cannot delete an active workflow ..." }.
500 on failure: { "error": "<message>" }, or { "error": "Delete failed" }.
Side effects: cascade-deletes the workflow’s dependent records, then the workflow row.
GET /api/workflows/{id}/debug
Returns a failure analysis for a workflow: a root cause, a timeline, fix suggestions, and per-step errors.
rootCause.type is one of budget_exceeded, timeout, transient, unknown. timeline[].severity is one of success, warning, error. suggestions[].tier is one of quick, better, best.
Errors: 500 on failure, including a workflow that does not exist: { "error": "<message>" }, or { "error": "Analysis failed" }.
Side effects: reads only.
GET /api/workflows/{id}/documents
Lists a workflow’s document bindings with the attached document metadata. A workflow with no bindings, and a workflow that does not exist, both return an empty array.
400 when the request body is not valid JSON: { "error": "Invalid JSON body" }.
400 when the body is invalid or includes stepId: { "error": "documentIds must be an array; stepId is not accepted for replacement" }.
404 when the workflow does not exist.
404 when any document does not exist: { "error": "Documents not found: <ids>" }.
500 on transaction failure: { "error": "Failed to replace workflow documents" }.
Side effects: validates every document first, then deletes and recreates
workflow-level bindings in one SQLite transaction. Failed validation preserves
the previous set.
POST /api/workflows/{id}/execute
Starts or re-runs a workflow. It atomically claims the workflow into active and dispatches execution in the background.
404 when not found: { "error": "Workflow not found" }.
409 when the workflow is already running: { "error": "Workflow is already running" }.
500 when the pre-run reset fails: { "error": "Failed to reset workflow state" }.
Side effects: for a completed, failed, or crashed run, cancels orphaned tasks and resets the workflow to draft, then claims it into active (the guard against double-execution) and runs it in the background. The run later moves the workflow to completed, failed, or paused.
POST /api/workflows/{id}/resume
Resumes a delay-paused workflow now, rather than waiting for its resumeAt. This supports the sequence pattern.
404 when not found: { "error": "Workflow not found" }.
409 when the workflow is not paused: { "error": "Workflow is not paused (current status: <status>)", "status": "<status>" }.
Side effects: clears the pause and continues the run in the background from the next step.
GET /api/workflows/{id}/status
Returns a workflow’s full status: its per-step state, run history, and document lists. The shape depends on the workflow pattern.
Request: none.
Response200: an object keyed on pattern. Common fields include id, name, status, projectId, projectName, definition, liveTaskCount, runNumber, runHistory, stepDocuments, and parentDocuments. projectName is the current linked name or null. The non-loop patterns add a resumeAt (epoch milliseconds or null), per-step state, and a workflowState; the loop pattern adds loopState and loopConfig. runHistory entries are { runNumber, taskCount, completedCount, failedCount }.
Errors: 404 when not found: { "error": "Workflow not found" }.
Side effects: reads only.
POST /api/workflows/{id}/steps/{stepId}/retry
Retries a single failed step after validating the workflow and step state, then dispatches the continued run in the background.
409 when the workflow is active or the step is not failed.
500 for an unclassified retry failure.
Side effects: when the step is failed and the workflow is not active, resets the step, moves the workflow to active, and re-runs the step in the background.
POST /api/workflows/{id}/stop
Stops a running workflow. It cancels the workflow’s live tasks and marks the workflow and its state failed. This route acts synchronously.
404 when not found: { "error": "Workflow not found" }.
409 when the workflow is not running: { "error": "Workflow is not running (current status: <status>)" }.
500 when the workflow state cannot be parsed: { "error": "Failed to parse workflow state" }.
Side effects: cancels each live task, marks running and waiting steps failed, and sets the workflow status to failed. There is no stopped status.
GET /api/workflows/{id}/target
Previews every effective execution target Relay would use for the workflow without starting it.
Request: none.
Response200: { "kind": "workflow", "ready": true, "targets": [ ], "context": { ... }, "error": null }, with one target per executable step. context.cell contains only vocabularyVersion and instanceId; the context also identifies the workflow project’s effective working directory (or launch-workspace fallback). Data-directory and database paths remain restricted to the Settings instance response. Neither field creates another customer data or credential boundary.
Errors:
404 when not found: { "error": "Workflow not found" }.
409 when any target cannot be resolved: { "kind": "workflow", "ready": false, "targets": [], "context": { ... } | null, "error": { } }.
Side effects: reads workflow, profile, routing, and runtime configuration only.
POST /api/workflows/from-assist
Creates a workflow and its per-step tasks together, and optionally starts the run immediately.
Request body (JSON):
Field
Type
Required
Notes
name
string
yes
Non-empty.
definition
object
yes
A workflow definition, validated as in POST /api/workflows.
projectId
string
no
priority
number
no
Task priority. Defaults to 2.
assignedAgent
string
no
Fallback runtime for steps that do not set their own.
executeImmediately
boolean
no
When true, creates the workflow active and starts it.
status is started when executeImmediately is set, otherwise created. workflow is the full workflow row.
Errors:
400 when name is missing: { "error": "Name is required" }.
400 when the definition is missing or invalid: { "error": "Definition must include pattern and at least one step" }, or a specific message.
400 when a step’s runtime and profile are incompatible: { "error": "<message>" }.
500 on failure: { "error": "Failed to create workflow and tasks" }.
Side effects: in one transaction, inserts the workflow and one planned task per step. When executeImmediately is set, also starts the run in the background.
POST /api/workflows/optimize
Returns advisory optimization suggestions for a workflow definition. Suggestions are advisory; the route changes nothing.
type is one of permission_required, task_completed, task_failed, agent_message, budget_alert, context_proposal, context_proposal_batch, tier_limit. Resolved learning-proposal notifications are hidden from this list.
Errors: none returned explicitly. A database failure propagates unhandled.
Side effects: reads only.
PATCH /api/notifications/{id}
Sets a notification’s read flag and returns the updated row.
Request body (JSON):
Field
Type
Required
Notes
read
boolean
no
Defaults to true.
Response200: the updated notification row.
Errors: 404 when not found: { "error": "Not found" }.
Side effects: updates the notification’s read flag.
DELETE /api/notifications/{id}
Deletes a notification by id.
Request: none.
Response200: { "success": true }.
Errors: none returned. Deleting an id that does not exist still returns { "success": true }.
Side effects: deletes the notification row.
PATCH /api/notifications/mark-all-read
Marks every unread notification as read.
Request: none.
Response200: { "success": true }.
Errors: none returned explicitly.
Side effects: sets all unread notifications to read.
GET /api/notifications/pending-approvals
Returns the current pending approval and learning notifications as enriched payloads for an inbox.
Request: none.
Response200: a JSON array. Each element includes channel, notificationId, taskId, workflowId, toolName, permissionLabel, compactSummary, deepLink, supportedActionIds, title, body, taskTitle, workflowName, toolInput, createdAt, read, and notificationType. supportedActionIds lists the actions the client may offer, such as allow_once, always_allow, deny, open_inbox.
Errors: none returned explicitly. A database failure propagates unhandled.
Side effects: reads only.
GET /api/notifications/pending-approvals/stream
Streams the pending-approvals list as Server-Sent Events, pushing a new frame whenever the list changes.
Request: none. The stream ends when the client disconnects.
Response200, streaming, with headers Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive. Each data frame is the same pending-approvals array as the non-streaming route:
Code
data: [ ... ]
The first frame is always sent, and a new frame follows only when the list changes. A keepalive comment (: keepalive) is sent every 15 seconds. The list is polled about every 0.75 seconds.
Errors: none as an HTTP status; the route always returns the stream. A database poll failure is retried silently and does not produce a client frame.
Side effects: reads only.
GET /api/permissions
Returns all saved permission allow-patterns.
Request: none.
Response200: { "permissions": ["string"] }. Each entry is a pattern string such as Read or Bash(command:git *).
Errors: none returned explicitly.
Side effects: reads only.
POST /api/permissions
Adds a permission pattern to the allow-list. Adding a pattern that already exists is a no-op.
Request body (JSON):
Field
Type
Required
Notes
pattern
string
yes
Non-empty.
Response200: { "success": true }.
Errors: 400 when pattern is missing: { "error": "pattern (string) is required" }.
Side effects: appends the pattern to the allow-list.
DELETE /api/permissions
Removes a permission pattern from the allow-list. The pattern to remove is given in the body. Removing a pattern that is not present still succeeds.
Request body (JSON):
Field
Type
Required
Notes
pattern
string
yes
The exact pattern to remove.
Response200: { "success": true }.
Errors: 400 when pattern is missing: { "error": "pattern (string) is required" }.
Side effects: removes the pattern from the allow-list.
GET /api/permissions/presets
Lists the built-in permission presets, each marked with whether it is currently fully active.
risk is one of low, medium, high. A preset is active when all of its patterns are on the allow-list.
Errors: none returned explicitly.
Side effects: reads only.
POST /api/permissions/presets
Enables a preset by adding all of its patterns to the allow-list. This is additive and skips patterns already present.
Request body (JSON):
Field
Type
Required
Notes
presetId
string
yes
The preset to enable.
Response200: { "success": true }.
Errors:
400 when presetId is missing: { "error": "presetId (string) is required" }.
404 when the preset is unknown: { "error": "Unknown preset: <presetId>" }.
Side effects: adds the preset’s patterns to the allow-list.
DELETE /api/permissions/presets
Disables a preset by removing the patterns unique to it. Patterns shared with another still-active preset are kept.
Request body (JSON):
Field
Type
Required
Notes
presetId
string
yes
The preset to disable.
Response200: { "success": true }.
Errors:
400 when presetId is missing: { "error": "presetId (string) is required" }.
404 when the preset is unknown: { "error": "Unknown preset: <presetId>" }.
Side effects: removes the preset’s unique patterns from the allow-list.
GET /api/schedules
Lists all schedules, newest first.
Request: none.
Response200: a JSON array of schedule rows. Key fields include id, projectId, name, prompt, cronExpression, assignedAgent, agentProfile, recurs, status, maxFirings, firingCount, expiresAt, lastFiredAt, nextFireAt, type, heartbeatChecklist, createdAt, updatedAt. status is one of active, paused, completed, expired. type is one of scheduled, heartbeat.
Errors: none returned explicitly. A database failure propagates unhandled.
Side effects: reads only.
POST /api/schedules
Creates a scheduled or heartbeat schedule, turning the interval into a cron expression and staggering it to avoid collisions with other schedules in the same project.
schedule is the created schedule row. warnings is empty unless the new schedule overlaps a busy schedule in the same project.
Errors:
400 when name is missing: { "error": "Name is required" }.
400 when interval is missing: { "error": "Interval is required" }.
400 when a scheduled schedule has no prompt: { "error": "Prompt is required" }.
400 when a heartbeat schedule has no checklist: { "error": "Heartbeat schedules require at least one checklist item" }.
400 on out-of-range active hours: { "error": "Active hours start must be 0-23" }, or the end equivalent.
400 when the interval cannot be parsed, or a profile is incompatible: { "error": "<message>" }.
Side effects: inserts one schedule row, staggering its cron minute to avoid collisions, and links any documents. It does not fire the schedule.
GET /api/schedules/{id}
Fetches one schedule with its firing history.
Request: none.
Response200: the schedule row, with heartbeatChecklist parsed to an array (or null), plus a firingHistory array of the tasks this schedule has fired, newest first.
Errors: 404 when not found: { "error": "Schedule not found" }.
Side effects: reads only.
PATCH /api/schedules/{id}
Updates a schedule, including pausing and resuming it.
Request body (JSON), all fields optional:
Field
Type
Notes
status
"paused" | "active"
Pause or resume.
name
string
Non-empty when given.
prompt
string
Non-empty when given.
interval
string
Re-parsed to a new cron expression.
assignedAgent
string
agentProfile
string
heartbeatChecklist
array
activeHoursStart
number | null
activeHoursEnd
number | null
activeTimezone
string
heartbeatBudgetPerDay
number | null
Response200:
JSON
{ "schedule": { }, "warnings": [ ] }
schedule is the updated row; warnings follows the same collision shape as POST /api/schedules.
Errors:
404 when not found: { "error": "Schedule not found" }.
409 when pausing a non-active schedule: { "error": "Can only pause an active schedule" }.
409 when resuming a non-paused schedule: { "error": "Can only resume a paused schedule" }.
400 on an invalid status: { "error": "Invalid status: <status>" }.
400 when a provided name or prompt is blank: { "error": "Name cannot be empty" }, or the prompt equivalent.
400 when the interval cannot be parsed, or a profile is incompatible: { "error": "<message>" }.
Side effects: updates the schedule. Pausing clears the next fire time; resuming recomputes it. It does not fire the schedule.
DELETE /api/schedules/{id}
Deletes a schedule by id.
Request: none.
Response200: { "deleted": true }.
Errors: 404 when not found: { "error": "Schedule not found" }.
Side effects: deletes the schedule row.
POST /api/schedules/{id}/execute
Fires a schedule now: it creates a queued task, atomically claims a concurrency slot, and dispatches the task in the background. Add ?force=true to bypass the concurrency cap.
Request query parameter:
Parameter
Type
Notes
force
"true"
Bypasses the concurrency cap and records an audit entry.
404 when not found: { "error": "schedule_not_found" }.
429 when the concurrency cap is full and force is not set: { "error": "capacity_full", "message": "Swarm at capacity (<running>/<cap>). Retry in ~60s or add ?force=true to bypass.", "slotEtaSec": 60 }.
Side effects: creates a queued task, claims a run slot, and runs the task in the background. When the slot cannot be claimed, the task is deleted again. This route does not advance the schedule’s own firing count or next fire time.
GET /api/schedules/{id}/heartbeat-history
Returns recent heartbeat evaluation entries and summary stats for a heartbeat schedule.
400 when expression is missing: { "error": "Expression is required" }.
400 when it cannot be parsed: { "error": "Could not parse \"<input>\". Try expressions like \"every weekday at 9am\", \"hourly\", \"5m\", or a cron expression." }.
Side effects: reads only.
GET /api/workshop/preflight
Checks whether the built-in Relay Operator Workshop can start in the current local instance.
Request: none.
Response200: a preflight object with ready, edition identity and hash, Relay version compatibility, data-directory writability, configured runtimes, deterministic-fallback availability, fixture integrity, and a failures array. Each failure includes code, message, and recovery.
Errors: 500 with the standard workshop error payload when preflight fails unexpectedly.
Side effects: reads local release, data-directory, runtime, and built-in fixture state.
POST /api/workshop/start
Installs or reuses the content-addressed built-in workshop starter.
Request body (JSON), strict: { "editionId": "relay-operator-workshop", "confirmInstall": true }.
Response201: a workshop run view containing edition identity, run status, linked project, app, workflow, and receipt ids, fallback state, checkpoint results, completion counts, errors, and timestamps.
Errors:
400 when explicit installation confirmation is missing or invalid.
409 on an installation conflict.
412 when the workshop edition is incompatible with the Relay version.
422 for another named workshop failure.
Side effects: creates the local workshop app, table, project, governed workflow, and persisted workshop run. The operation is idempotent for the built-in edition and does not overwrite conflicting user work.
GET /api/workshop/{id}
Returns the current evaluated state for one workshop run.
Request: none.
Response200: the workshop run view, including five checkpoint results. Each checkpoint includes its source route, status, detail, and optional recovery.
Errors: 404 with { "error", "code", "recovery" } when the run does not exist.
Side effects: reads the run and evaluates its linked app, table, workflow, and Operations Receipt.
POST /api/workshop/{id}
Re-evaluates checkpoint state and clears a recoverable stored workshop error.
Request: none.
Response200: the refreshed workshop run view.
Errors: 422 with the standard workshop error payload.
Side effects: updates the run status, error fields, and timestamp after evaluating current evidence.
POST /api/workshop/{id}/fallback
Runs the explicitly selected deterministic workshop rehearsal without a model or provider call.
Request: none.
Response200: the updated workshop run view. Repeated calls return the existing terminal evidence when a receipt is already present.
Errors: 422 with a named workshop error and recovery instruction.
Side effects: creates deterministic task and document evidence, completes the governed workflow, composes an Operations Receipt, and marks the run as having used fallback.
GET /api/workshop/{id}/export
Builds and downloads the redacted workshop completion bundle.
Request: none.
Response200: a ZIP attachment named relay-workshop-completion-<id>.zip, with Cache-Control: no-store.
Errors: 422 when terminal evidence is missing, redaction fails, or another named workshop export error occurs.
Side effects: builds the learner-owned app Pack and selected completion evidence, then marks the retain checkpoint passed after successful bundle construction.
GET /api/workshop/handoff
Downloads the versioned production handoff used by Relay, Website, and Motion workshop surfaces.
Request: none.
Response200: a JSON attachment named relay-operator-workshop-production-handoff.json, with Cache-Control: no-store.
Errors: none returned explicitly.
Side effects: reads only.
Examples
Instantiate a blueprint into a draft workflow, then run it:
The -N flag keeps curl from buffering, so you see each frame as it arrives.
Check what a schedule interval resolves to before creating it:
Terminal
curl -X POST http://127.0.0.1:3000/api/schedules/parse \ -H "Content-Type: application/json" \ -d '{ "expression": "every weekday at 9am" }'
Do Not Depend On
A workflow’s top-level status has no running, stopped, or cancelled value. A run in progress is active; a stopped workflow is failed. paused covers both a delay wait and a human-input wait; tell them apart by the presence of resumeAt and the per-step state, not the top-level status.
execute, resume, and step retry return 202 before the work runs. A 202 means the run was claimed and dispatched, not that it succeeded. Poll the status route.
POST /api/workflows/{id}/steps/{stepId}/retry returns 202 before it validates anything, so an invalid retry looks identical to a valid one over HTTP. Confirm the outcome by polling the status route.
GET /api/workflows/{id}/debug returns 500, not 404, for a workflow that does not exist, because the analysis throws on a missing workflow.
GET and DELETE on /api/workflows/{id}/documents do not check that the workflow exists; a missing workflow yields an empty list or a success. POST and replace-all PUT validate the workflow.
Pausing a workflow through PATCH /api/workflows/{id} flips the status but does not stop tasks that are already running. Use POST /api/workflows/{id}/stop to cancel live tasks.
The POST /api/schedules/{id}/execute not-found body is { "error": "schedule_not_found" }, while the other schedule routes use { "error": "Schedule not found" }. Do not match on one spelling across all schedule routes.
Permission mutations return only { "success": true }; they do not echo the resulting allow-list. Re-read GET /api/permissions to observe the new state.
Orionfold Relay
Run your AI team from one place.
The engine is free and open. A license unlocks the premium packs you own and keeps them yours, offline. Founding seats from $349.