Orkestia
Blog
Build apps

End-user authoring

Let the end users of your app build, check, publish, run and schedule their own small workflows (compositions) inside their workspace.

Your app's end users can build their own automations. Each one is a composition: a small workflow made of steps you allow. A person builds it, checks it, saves drafts, publishes it, runs it and puts it on a schedule, all inside their workspace.

How it works

  • A workspace owns it. A composition belongs to the workspace that was active when it was saved. Other workspaces cannot see it, run it or change it. A composition of another workspace answers NOT_FOUND, the same answer as one that does not exist.
  • Steps come from your catalog. You choose which workflow types your end users may use as steps. A step outside that catalog is refused.
  • It runs as the person. A run of an end user's composition has that person's authority in that workspace, never more. The organization, app, person and workspace always come from the session, never from the request.
  • Roles decide who does what. By default, members can build drafts and run compositions. Admins and owners can also publish and archive. You choose which groups get these rights.

The platform calls this end-user composition authoring. You turn it on per app.

Availability

Two switches must both be on:

  1. Your organization is on the authoring allowlist. Authoring is live for customer organizations on an allowlist. Ask Orkestia to add your organization. Until then, identity.app.enable-authoring fails with AUTHORING_NOT_ENABLED and nothing is written.
  2. Your app turns it on with identity.app.enable-authoring.

You need an app with Sign in with Orkestia end users and workspaces.

Turn it on

Three admin workflows control authoring for an app. A person who is an admin or owner of your organization must run them. Agent tokens and end users are refused. Each takes identity_app_uuid, plus actor and organization_uuid like every identity.app.* admin workflow.

1. Choose the steps

identity.app.set-authoring-catalog sets the authoring catalog: the workflow types your end users may use as steps.

InputMeaning
workflow_typesA list of library workflow types. Required. With mode: "replace", [] empties the catalog.
modereplace (default), add or remove
{
  "identity_app_uuid": "<identity_app_uuid>",
  "workflow_types": ["<step-type-a>", "<step-type-b>"],
  "mode": "replace"
}
  • Every type that replace or add puts in the catalog must be marked end-user eligible by the platform. An app override from identity.app.set-workflow-eligibility does not count, because the end user controls every input of the step. Otherwise the run fails with STEP_NOT_AUTHORABLE, failure_details.workflow_types lists the types, and nothing is written.
  • remove is never checked, so you can always take a type out.
  • Compositions (virtual.*) and composition references cannot be catalog entries.
  • You can set the catalog before or after you enable authoring.

It returns authoring_catalog (after the change), added, removed and changed.

2. Enable authoring

identity.app.enable-authoring turns authoring on.

InputMeaning
groupsAccess group slugs to grant authoring to. Default ["owner", "admin", "member"].
limitsOverrides of the limits, key by key. Positive integers. An unknown key is refused.
{
  "identity_app_uuid": "<identity_app_uuid>",
  "groups": ["owner", "admin", "member"],
  "limits": { "live_lineages_per_workspace": 10 }
}

It does three things:

  1. Turns authoring on for the app.
  2. Grants the capabilities to the chosen groups. owner and admin get read, run, draft and publish. Every other chosen group gets read, run and draft. Grants are only added, never removed, so grants you made yourself stay.
  3. Publishes and exposes the app's entry points: one small composition per action, named composer-<key>-<app12>. <app12> is the first 12 hex characters of the app's UUID, without dashes. See Entry points.

It returns authoring_enabled, was_enabled, groups, capabilities_added ({group: [capabilities]}), authoring_limits (the limits now in effect), entrypoints, published and exposed. entrypoints maps each key to {workflow_type, composition_uuid, version, name, wraps, revision, exposed}. Keep the workflow_type values: your app starts them.

  • The run can take a few minutes, because it publishes the entry points before it finishes.
  • It is safe to run again. A second run only adds what is missing: a capability, an entry point, or an entry point whose definition changed in a platform update. If it fails with AUTHORING_ENTRYPOINT_FAILED, run it again and it continues where it stopped.
  • limits replaces the app's earlier overrides. A key you leave out goes back to its default. Leave limits out to keep the current overrides.

3. Give people a workspace

Authoring works only in an active workspace of the app. Add people to a workspace with a role, as described in Workspaces. Their role in that workspace decides what they can do there.

Who can do what

Capabilities are resolved per workspace, from the person's role in their active workspace.

CapabilityLets the person
compositions:readList and read the workspace's compositions
compositions:runStart a published composition of the workspace
compositions:draftCheck compositions, see the step catalog, and save drafts of new compositions and of compositions they started
compositions:publishEverything compositions:draft allows, plus publish, archive, change compositions other people started, and see everyone's drafts

With the default grant:

MemberAdmin or owner
List, read and run published compositionsYesYes
Check, see the step catalog, and save drafts of their own compositionsYesYes
See other people's draftsNoYes
Change a composition someone else startedNoYes
Publish and archiveNoYes

Two more rules:

  • Authors keep access to their own work. A person can always read and run a composition they wrote, even without compositions:read or compositions:run, while they are a member of the workspace.
  • Drafts stay private to their author and to publishers. Without compositions:publish, a person sees the published compositions up to their live version, plus everything they started or wrote. Anything else answers NOT_FOUND.

So a member can prepare a composition, and an admin reviews and publishes it.

Entry points

An end user's browser can start only compositions your app exposes, never a library workflow. So identity.app.enable-authoring publishes one entry point per action. Each one is a one-step composition that wraps a composition.app.* verb (or a workspace or schedule verb) and passes the caller's inputs through unchanged.

Start an entry point with the end user's token, using the workflow_type from entrypoints. The verbs read the app, the workspace and the person from the session. You never send them.

KeyWrapsInputsNeeds
listcomposition.app.liststatus, limit, cursorcompositions:read (or own compositions only)
getcomposition.app.getcomposition_uuid, or lineage_name / slug with optional versioncompositions:read (or the author)
catalogcomposition.app.catalognonecompositions:draft or compositions:publish
validatecomposition.app.validatedefinition (required), include_dry_run, summary_onlycompositions:draft or compositions:publish
save-draftcomposition.app.save-draftdefinition (required), slug, lineage_name, title, expected_versioncompositions:draft or compositions:publish
publishcomposition.app.publishdefinition, expected_version, idempotency_key (all required), slug, lineage_name, titlecompositions:publish
archivecomposition.app.archivecomposition_uuid, or lineage_name / slug with versioncompositions:publish
publish-statuscomposition.app.publish-statusidempotency_key (required)The person who published
workspacesidentity.end-user.organization.querynone. Returns items, total—
workspace-switchidentity.end-user.organization.switchworkspace_uuid (required). Returns active_app_organization_uuid, app_organization_ref, membership_statusA member of that workspace
schedule-list, schedule-runs, schedule-create, schedule-update, schedule-pause, schedule-resume, schedule-deleteschedule.app.list, .runs, .create, .update, .pause, .resume, .deleteThe verb's inputs. See SchedulesChanges only the person's own schedules

Read each verb's fields from the entry point run's output. They can sit under the step's name, which is the key with - written as _ (for example save_draft).

A definition is a composition definition in the same format composition.save takes. See Compositions.

Names, titles and versions

  • A composition is one lineage of numbered versions. Name it with a slug: lowercase a-z0-9 words joined by single hyphens, at most 64 characters. Without a slug, one is made from title.
  • The stored name is ws-<workspace id>-<slug>. list returns it as lineage_name. You can pass it back instead of the slug.
  • title is the display name, at most 200 characters. A new composition needs one. Later saves keep it when you leave it out.
  • expected_version is the newest version your editor holds, of any status. Send 0 for a new composition. If someone saved a newer version meanwhile, the write is refused with VERSION_CONFLICT and head_version. It is optional on save-draft and required on publish.

Check

validate writes nothing. It runs the platform's validation and the authoring rules, and tells you whether publish would accept the definition.

OutputMeaning
passed, errors, warningsThe platform's validation
authoringThe authoring rules: {passed, findings, codes, step_count, step_types}
findingsEach problem: {code, message, ...}, with step_name, path, workflow_type or field when known
publishablepassed and authoring.passed. valid is the same value.
pinned_childrenNested compositions of this workspace that a publish would pin to their live version

The authoring rules, with the code each finding carries:

  • Every step type, at every depth, is end-user eligible, and every top-level step is in the app's catalog (STEP_NOT_AUTHORABLE).
  • No step sets organization_uuid, user_uuid, end_user_uuid, identity_app_uuid or app_organization_uuid as a fixed value. Only organization_uuid may come from the run's input (UNSCOPED_INPUT).
  • A nested composition is pinned to one exact version, belongs to this workspace or the organization, and is exposed to this workspace (NESTED_NOT_IN_WORKSPACE). A nested composition of this workspace referenced as @latest is pinned for you to its live version.
  • The step count is within steps_per_composition (QUOTA_EXCEEDED).

catalog lists the allowed steps in catalog order: workflow_type, name, description, authorable, and schema with input_fields and output_fields. An input field with server_controlled: true is filled by the platform: don't ask the person for it. A type that is no longer eligible stays in the list with authorable: false and a reason (not_end_user_eligible or unknown_type). catalog also returns the app's limits.

Save a draft

save-draft saves a new version with status draft. It never publishes or exposes anything. It returns composition_uuid, lineage_name, slug, title, version, head_version, status (draft), workflow_type, validation_passed, validation_errors and validation_warnings.

Publish

publish saves a new version, checks it, makes it the live version and exposes it to this workspace only, at this version only.

  • idempotency_key: choose one per publish attempt (for example a UUID, at most 128 characters) and reuse it on every retry of that attempt. A retry of a finished key returns the stored outcome with replayed: true. Nothing new is written. The same key used by another person, workspace or lineage is refused with IDEMPOTENCY_KEY_REUSED.
  • Nested compositions of this workspace referenced as @latest are pinned to their live version. Those that were not yet exposed to the workspace are exposed with it.

The output is the publish request:

FieldMeaning
statuspublished, refused, failed, or accepted (still running)
refusal_codeThe code when refused; INTERNAL_ERROR or PUBLISH_ABANDONED when failed; PUBLISH_IN_PROGRESS when accepted; null when published
refusal{code, message, ...} with what the refusal names: head_version, findings, errors, limit and maximum, or capability
receiptWhen published: composition_uuid, lineage_uuid, lineage_name, title, version, workflow_type, orn_pinned, published_at, request_uuid, pinned_children
replayedtrue when this key was already used
request_uuid, idempotency_key, lineage_name, expected_version, created_at, finished_atThe request itself

After the request is accepted, a refusal does not fail the run. It is recorded and returned with status: "refused", so publish-status can report it later. A definition that fails validation is kept as a draft and refused with VALIDATION_FAILED. Refusals found before the request is recorded fail the run instead: no workspace, authoring off for the organization, a malformed input, or another workspace's lineage_name.

If a publish times out on your side, call publish-status with the same key before you retry. It returns the same shape. A request still accepted after 15 minutes is reported as failed with PUBLISH_ABANDONED. Publish again with a new key.

List

list returns one item per lineage, newest change first, up to limit (default 50, at most 200). Pass next_cursor back as cursor for the next page. Filter with status: live, draft or retired.

Each item has lineage_name, slug, title, status, head and live (or null), step_count, updated_at, author ({mine, display_name}), exposed and availability:

availabilityMeaning
availablePublished and served to this workspace. People can run it.
unexposedPublished, but not served to this workspace, for example after authoring was turned off with unexpose. Publish it again to expose it.
not_publishedNo live version.

list also returns view (workspace, published or own, depending on what the person may see) and capabilities: {read, run, draft, publish} for the person in this workspace. Use capabilities to show or hide actions. author.display_name is the part of the person's email before the @. No member UUID is returned.

get returns one version with its definition, validation, head_version, live_version, exposed, author, lineage_author and capabilities.

Archive

archive retires one version: it can no longer be started, and its exposure to the workspace is removed. Schedules that fire it stop at their next firing. It returns composition_uuid, lineage_name, version, status (retired), previous_status and unexposed.

Run a published composition

A published version is a normal workflow type, virtual.<composition_uuid>@<version>. Find it in the publish receipt.workflow_type, or in live.workflow_type from list. Start it with the end user's token:

const run = await client.start("virtual.<composition_uuid>@<version>", {
  // the composition's own inputs only
})

The start succeeds only when:

  • the version is exposed to the person's active workspace, otherwise 403 with NOT_EXPOSED_TO_END_USERS;
  • the person holds compositions:run in that workspace or wrote the composition, otherwise 403 with CAPABILITY_REQUIRED and capability: "compositions:run";
  • a session bound to a workspace is still in that workspace, otherwise 403 with WORKSPACE_MISMATCH (see Sessions bound to a workspace).

Don't send app_organization_uuid in the input. The active workspace comes from the session, and a start whose app_organization_uuid differs from it is refused with PRINCIPAL_OVERRIDE_REJECTED.

Schedules

End users schedule published compositions with the schedule.app.* workflows, through the app's composer-schedule-<verb>-<app12> entry points. A schedule runs as the person who created it, in their workspace.

WorkflowWhat it does
schedule.app.createCreate a schedule. Inputs: target_workflow_type, name, interval_minutes or schedule_expression with timezone, initial_data, active
schedule.app.updateChange the name, cadence, target, inputs or active of your own schedule
schedule.app.pause, schedule.app.resume, schedule.app.deleteAct on your own schedule
schedule.app.listThe schedules you can see in this workspace
schedule.app.runsThe recent runs (last 30 days) one schedule started: status and timing
  • Cadence: either interval_minutes, or schedule_expression (five cron fields, for example 0 8 * * 1-5) read in timezone, an IANA name such as America/Sao_Paulo. Not more often than every 15 minutes.
  • Target: a composition your app exposes to the person's workspace, as virtual.<uuid>@<version> or a composition reference. A raw workflow type is refused with schedule_target_not_exposed.
  • New schedules start paused unless you send active: true.
  • Limits: by default a person may hold 10 schedules in an app and an app 50, paused ones included (schedule_quota_exceeded). A cadence that is too short is refused with schedule_interval_too_short.
  • Ownership: people change only the schedules they created. Anything else answers schedule_not_found.

The schedule.app.* workflows are library workflows, so the browser starts them through the schedule-* entry points. An app that turned authoring on before those entry points existed gets them by running identity.app.enable-authoring again; nothing else changes.

Skipped firings

Before each firing, the platform checks again that the creator is still an active member of the workspace and that the composition is still exposed to it. If not, it skips the firing and starts nothing. So when a person leaves the workspace, their schedules stop running.

schedule.app.list shows it on each schedule:

FieldMeaning
last_fire_skippedtrue when the last occurrence started nothing
last_error_codeWhy, for example creator_not_in_workspace, workspace_inactive or target_not_exposed_to_workspace. Other values describe failed runs, such as run_failed or start_rejected.
last_error_reasonA fixed sentence for the code, safe to show to the person
last_run_atWhen the last occurrence came due, run or skipped
run_countOccurrences that started a run. Skips are not counted.

Each item also has schedule_uuid, name, target, interval_seconds, schedule_expression, timezone, active, next_run_at, last_status, consecutive_failures, paused_reason, locked_by_admin and mine. initial_data appears only on the person's own schedules.

schedule.app.runs lists only runs, so it adds last_skip: {at, code, reason} for the latest skip, or null. Only the latest skip is kept.

A person who holds schedules:manage in the workspace can also pause, resume, edit the timing of, archive, reassign or delete other people's schedules with schedule.app.admin.*. An admin pause locks the schedule, so its creator cannot resume it until an admin unlocks it. identity.app.enable-authoring does not grant schedules:manage.

Refusal codes

A refused verb fails its run. failure_reason starts with the code, for example VERSION_CONFLICT: the composition changed: .... On the verb's own run, failure_details holds {code, ...} with what the refusal names. When you call the verb through an entry point, failure_details may be missing from the entry point's run, so read the code from the start of failure_reason. publish is the exception: once a request is recorded, it returns the code in refusal_code instead of failing.

CodeMeaningWhat your app should do
AUTHORING_NOT_ENABLEDThe organization is not on the allowlist, or the app has not enabled authoringHide the authoring screens. Ask an admin to run identity.app.enable-authoring.
WORKSPACE_REQUIREDThe session has no active workspaceLet the person pick a workspace (workspace-switch)
WORKSPACE_MISMATCHThe active workspace is not an active workspace of this app. At start, a session bound to another workspace (see reason).Switch workspace, or refresh the session for the active one
CAPABILITY_REQUIREDThe person lacks the capability named in capability in this workspaceShow the screen read-only. A workspace admin can publish for them.
NOT_FOUNDNo such composition or publish in this workspace, or one the person may not seeRefresh the list
VERSION_CONFLICTSomeone saved a newer version; head_version names itReload with get, let the person review, then retry with the new expected_version
PUBLISH_IN_PROGRESSA publish with this key is still runningPoll publish-status with the same key
VALIDATION_FAILEDThe definition does not validate; on publish it was kept as a draft. errors lists why.Show the errors and let the person fix them
STEP_NOT_AUTHORABLEA step is not in the app's catalog or not end-user eligibleRemove the step. An admin can add an eligible type to the catalog.
UNSCOPED_INPUTA step sets or takes a platform-controlled fieldRemove that mapping
NESTED_NOT_IN_WORKSPACEA nested composition is not pinned to one version, not in this workspace, or not exposed to itPublish the nested composition first, then retry
QUOTA_EXCEEDEDA limit is reached; limit and maximum name itTell the person. Archive old work, or raise the limit.
INVALID_NAMEThe slug, lineage_name or title is malformed, or a new composition has no titleAsk for a valid name
RESERVED_NAMEThe ws- name prefix is reserved for workspace compositionsUse another name
IDEMPOTENCY_KEY_REUSEDThe key was used for another publishPublish with a new key
PUBLISH_ABANDONEDA publish never finishedPublish again with a new key
INTERNAL_ERRORThe publish failed for a reason that is not a refusal. If exposing failed, the version was kept as a draft.Publish again with a new key
AUTHORING_ENTRYPOINT_FAILEDidentity.app.enable-authoring could not publish an entry pointRun it again

Limits

LimitDefault
live_lineages_per_workspace: published compositions in one workspace25
versions_per_lineage: versions of one composition200
steps_per_composition: steps in one composition40
publishes_per_person_per_day: successful publishes per person in 24 hours50
drafts_per_person: drafts one person keeps in a workspace100

Change them with limits on identity.app.enable-authoring. A limit that is reached refuses with QUOTA_EXCEEDED. Schedules have their own limits.

The ready-made editor

@orkestia/composer is an open-source (MIT) React editor for compositions: the list, each composition as steps or a diagram, checks, runs and schedules. Its appBackend uses the entry points above.

It is not on npm. Install the release tarball from GitHub, and pin the version you tested:

pnpm add https://github.com/orkestia/orkestia-composer/releases/download/v0.1.2/orkestia-composer-0.1.2.tgz

react and react-dom 19.2 or later are peer dependencies.

import { appBackend, CompositionEditor } from '@orkestia/composer'
import '@xyflow/react/dist/style.css'
import '@orkestia/composer/style.css'

// endUserClient runs workflows with the end user's token.
const backend = appBackend(endUserClient)

export function Automations() {
  return <CompositionEditor backend={backend} host={{ scopeKey: `${appId}:${workspaceId}` }} />
}

appBackend finds the composer-<key>-<app12> entry points among your organization's compositions. If more than one app in your organization has authoring on, pass appBackend(endUserClient, { app12 }), where app12 is the first 12 hex characters of your identity app's UUID, or pass entryPoints with the workflow_type values identity.app.enable-authoring returned. The step picker offers only your app's authorable steps, and the editor offers only what the person's capabilities in the workspace allow. See the repository README for the client interface, theming, languages and schedules.

Not in this phase

  • Event triggers: an end user's composition starts by hand or on a schedule, not on an event.
  • Step-by-step run logs for end users. schedule.app.runs gives status and timing only.
  • A calendar source by date range.
  • Checks at publish time that a referenced vault credential is usable.

Turn it off

identity.app.disable-authoring turns authoring off for the app. The composition.app.* verbs then refuse with AUTHORING_NOT_ENABLED. It works even if your organization has left the allowlist, so you can always roll back.

InputMeaning
unexposeDefault false. With true, also remove every workspace exposure. Those compositions can no longer be started, and schedules that fire them stop at their next firing.
{ "identity_app_uuid": "<identity_app_uuid>", "unexpose": true }

It returns authoring_enabled (false), changed, unexpose and unexposed ([{composition_uuid, version, app_organization_uuid}]). It is safe to run again.

Nothing is deleted. Compositions, the catalog, the limits, the capability grants and the app-wide entry points stay. Running identity.app.enable-authoring again turns authoring back on as it was. Workspace exposures removed with unexpose do not come back: those compositions show availability: "unexposed" until someone publishes them again.