Documentation
On this page

Business actions

An action is one of your organization's business operations, such as "open a work order" or "close an incident". It takes typed inputs, checks who may run it and whether it still applies, then makes all its changes at once or none of them.

Objects describe what exists; actions describe what someone may make happen. Without them, opening a work order for an incident takes several requests (create the order, link it, update the incident), and a failure halfway leaves the data half changed. An action does the same work in one request and one transaction, under rules the organization defines once and every caller meets: your app, a person’s assistant over MCP, and agent accounts.

An example

JSON
{
  "api_name": "open_work_order",
  "display_name": "Open a work order",
  "description": "For an open incident",
  "parameters": [
    { "api_name": "incident", "object_type": "incident",
      "required": true },
    { "api_name": "summary", "type": { "kind": "string" },
      "required": true },
    { "api_name": "code", "type": { "kind": "string" } }
  ],
  "criteria": [
    { "name": "open",
      "message": "the incident is not open",
      "condition": { "op": "eq",
        "left": { "object": "incident", "property": "state" },
        "right": { "value": "open" } } },
    { "name": "four_eyes",
      "condition": { "op": "ne",
        "left": { "caller": "id" },
        "right": { "object": "incident",
                   "property": "$created_by" } } }
  ],
  "edits": [
    { "op": "create_object", "type": "work_order",
      "ref": "order",
      "properties": { "summary": { "param": "summary" },
                      "code": { "param": "code" } } },
    { "op": "create_link", "link": "orders",
      "from": { "param": "incident" },
      "to": { "created": "order" } },
    { "op": "update_object", "object": { "param": "incident" },
      "properties": { "assignee": { "caller": "id" } } }
  ],
  "effects": [
    { "kind": "webhook",
      "url": "https://hooks.acme.example/work-orders" }
  ]
}

Applying it:

HTTP
POST /v1/actions/open_work_order/apply
{ "params": { "incident": "o4b8j2e6c0t1q5w9r3y7u2i6",
              "summary": "Replace the seal" },
  "idempotency_key": "triage-2026-10-25-42",
  "expected_versions": { "incident": 3 } }

creates the work order, links it to the incident, and assigns the incident to the caller, in one transaction. If the incident is closed, if the caller opened it themselves, or if it changed since the caller read version 3, nothing changes and the response says which.

Who may apply an action

  • Defining an action needs manage_ontology and view access to every object type it names.
  • Applying it needs a grant: owners grant teams the right to apply each action, and owners may always apply them. The grant stands in for edit_objects, so a team can open work orders without being able to edit incidents freely.
  • The caller still needs view access to every object type the action touches. Restricting a type hides the actions that touch it from anyone who may not view it.
  • Actions see only the objects the caller can see. With shared and assigned objects, passing an object that is not shared with the caller is refused like an unknown one (422, at params.<name>), and edits that would take an object out of the caller’s sight are 403. A file_ref parameter must name a file the caller can read.
  • Actions read and write whole objects, including restricted properties the caller may not see: the grant stands in for property access as it does for edit_objects. The result names objects by id only.
  • Through MCP, the grant also needs the actions:apply scope, and ontology:read for the touched types. See MCP and OAuth.

GET /v1/actions lists the actions the caller may apply, with a JSON Schema of each one’s parameters, so a client or an agent can build the request.

Parameters

Parameters, the action’s inputs, are typed like properties: a type, required, and rules, custom validators and formats included. Or a parameter names an object_type, and its value is the id of a live object of that type. The names idempotency_key and expected_versions are reserved.

Sent, null, or absent

An input is present when the request names it, null included, or when the server fills it; otherwise it is absent. In an edit:

  • a value sets the property;
  • null clears it;
  • an absent input sets nothing, and the property keeps its value.

So one “Edit incident” action can change any of an incident’s fields, and a request that sends only the title changes only the title. With "blank": "clear", a string input reads a blank string (after trimming) as null, so emptying a text box clears the field. For a single expression, {"param": "note", "blank": "null"} does the same.

An input that sets a property

sets makes an input the edit of one property, of an object parameter ({"object": "target", "property": "title"}) or of an object the action creates ({"created": "order", "property": "summary"}). A present input is merged into that property, and an absent one leaves it alone. These edits come before the explicit edits, and an explicit edit may not set the same property. sets is for value inputs.

Who may set an input

An input’s condition, with an optional condition_message, is checked only when the input is present, together with the criteria. It decides who may change one field inside an action that others may apply:

JSON
{ "api_name": "priority", "type": { "kind": "string" },
  "rules": [{ "rule": "enum", "values": ["low", "high"] }],
  "sets": { "object": "target", "property": "priority" },
  "condition": { "op": "listed", "in": { "object": "target", "property": "leads" } },
  "condition_message": "Only the incident's leads may set the priority" }

Anyone granted the action may change the title, but a request that sends a priority, even null, from someone the incident does not list as a lead is refused with that message (422 criteria_failed, path params.priority). An absent input’s condition never matters.

Inputs the server fills

fill makes the server set an input: {"caller": "id"} (who is applying it), {"now": true}, {"value": ...}, or {"object": "target", "property": "..."} (a property of an object parameter). A filled input is always present, a request that sends it is refused (422), and it is left out of the parameters’ JSON Schema. With sets, it records who closed an incident and when, without trusting the client to say:

JSON
{ "api_name": "closed_by", "type": { "kind": "user_ref" }, "fill": { "caller": "id" },
  "sets": { "object": "target", "property": "closed_by" } }

How forms show an input

ui tells forms, such as the web app’s, how to show an input:

Field
label, helpthe input’s label (1-200 characters; also its description in the JSON Schema) and help text
orderits place in the form
controltext, textarea, number, date, datetime, boolean, enum, user, team, file, or, for object inputs only, object or objects
pickerobject inputs: {"filter": ...}, a query filter on the objects offered
defaulta prefill: {"value": ...}, {"target": true} (object inputs: the object the form was opened from), {"object", "property"}, or {"caller": "id"}. The server never applies it
visible_whena condition over other inputs and literals: forms hide the input while it is not true

visible_when is enforced: an input sent while its condition is not true is refused (422, params.<input>: not applicable). The rest is for display only.

Format 1

Definitions are stored with "format": 2, which every POST and PUT writes. Action types stored before formats existed show "format": 1 and keep reading an absent input as null: an absent optional input in an update_object edit removes the property, as it always did. Saving such a definition again stores it as format 2, so check what its absent inputs should do first.

Criteria

Every criterion must hold for the action to apply. A criterion is either a declarative condition or Python code.

Conditions combine and, or, not, eq, ne, lt, lte, gt, gte, in, is_null, and contains over these expressions:

ExpressionValue
{"param": "summary"}a parameter
{"object": "incident", "property": "state"}a property of a parameter’s object; also $id, $version, $created_by, $updated_by, $created_at, $updated_at
{"object": "incident", "via": ["building"], "property": "leads"}a property of the records linked to a parameter’s object; see Following links
{"caller": "id"}, {"caller": "kind"}, {"caller": "teams"}who is applying it, whether they are a human or an agent, and the ids of their teams
{"team": "maintainers"}the id of the team with that name, or null
{"user": <expression>, "field": "kind"}, "field": "teams"the kind or the team ids of another user: the expression is a user id, such as a user_ref parameter or property
{"now": true}the time of the request
{"value": "open"}a literal

contains holds when a list holds a value. With the team expressions it asks whether someone is on a team:

JSON
{ "op": "contains", "list": { "caller": "teams" }, "value": { "team": "maintainers" } }
JSON
{ "op": "eq",
  "left": { "user": { "object": "pull_request", "property": "author" }, "field": "kind" },
  "right": { "value": "agent" } }

The first holds when the caller is a maintainer, the second when the pull request’s author is an agent. Teams here are always effective teams: current memberships, plus every team above them along inherited subteam links, the same teams grants reach. A user who is not in the organization has no kind and no teams (null).

Two conditions look at inputs and lists of people:

  • {"op": "is_set", "param": "title"} holds when the input is present. A criterion such as “change at least one field” is an or of them.
  • {"op": "listed", "in": <expression>} holds when the value, a user_ref, a team_ref, or a list of them, names the caller, or a team they are effectively on. With "user": <expression> it asks about that user instead. It is the same test a listed_in read rule applies, so an action can allow exactly the people who read a record.

One more condition looks at links:

JSON
{ "op": "linked", "link": "reservation_car",
  "from": { "param": "reservation" }, "to": { "param": "car" } }

It holds when a link of that type, visible to the caller, joins the two object parameters’ objects: the reservation is for this car, the trade-in belongs to this deal. Without it, a caller could pass a car and someone else’s reservation and have the criteria judge them as a pair.

{"object", "via", "property"} reads a property, or $id, of the records linked to an object parameter. via lists link end names from it, at most 3 steps, such as ["building"] from an incident to its building:

JSON
{ "op": "listed", "in": { "object": "target", "via": ["building"], "property": "leads" } }

holds when the caller leads the incident’s building. Linked records are read as the caller sees them: under row level security, of types they may view, with only the properties they see. A path that may reach many records gives the list of their values, and is allowed only in listed, contains, and is_null. Paths are for conditions, not edits, fills, or defaults. The records a path reaches are read again when the edits run, and if what they hold changed, the application is 409 version_conflict, path via.

Conditions use three-valued logic. A path that reaches no record the caller sees is unknown, and so is any comparison with an unknown value:

  • not keeps unknown unknown;
  • and is false if any part is false, or true if any part is true;
  • a condition passes only if it is true.

A record hidden from the caller and a record that does not exist decide the same way, so a condition never reveals which it was, and not never turns either into a pass: in the example, an incident whose building the caller cannot see fails both the condition and its negation.

Code

Code defines check(ctx) and returns True, or a message saying why not. ctx is {action, params, objects, links, caller {id, kind, teams}, teams, users, now, present, via}:

  • links lists the links between the parameters’ objects, of the link types the definition uses, as {link, from, to};
  • teams maps team names to ids: every team when the definition has code, otherwise the teams it names;
  • users maps user ids to {kind, teams} for the caller, the users that user expressions name, the user_ref parameters, and the user_ref values of the parameters’ objects, at most 1000.
  • present lists the inputs that are present;
  • via holds the values of the definition’s via paths, keyed like target.building.leads, unknown ones as None. A code criterion lists the paths it reads in its own via, as {object, via, property}.
Python
def check(ctx):
    if ctx['objects']['incident']['properties']['severity'] >= 3:
        return True
    return 'only severe incidents are triaged'

A failed application lists every criterion that did not hold:

JSON
{ "error": { "code": "criteria_failed", "message": "the action's criteria are not met",
  "details": [{ "path": "criteria.four_eyes", "message": "the criterion is not met" }] } }

Edits

Edits are either a list or Python code that returns one:

EditDoes
create_object {type, ref?, properties}creates an object; later edits refer to it as {"created": ref}
update_object {object, properties}merges properties into a parameter’s object; null removes one, and an absent input sets nothing
create_link, delete_link {link, from, to}adds or removes a link
add_member {team, user, expires_at?}adds a user to a team, or renews their membership, until expires_at or for good
remove_member {team, user}ends a user’s membership of a team
delete_object {object}moves an object parameter’s object to the trash; a team object is deleted for good
restore_object {object}brings a trashed object parameter’s object back
purge_object {object}takes a trashed object parameter’s object out of the trash for good, with its links, series, channels, and the attachments only it used

Values are expressions, as in conditions. Edits reach only the parameters’ objects and the objects the action creates. Each edit is an ordinary write: checked against its type’s rules, validators, uniqueness, and link cardinality, with its own events, and recorded as made by the caller.

The team of a membership edit is an object parameter of a team type, so owners and everyone are out of reach; only active users of the organization join, and expires_at must be in the future (422 otherwise, at the edit’s path). A membership edit is recorded in the team’s history as made by the caller and sent as a team.member_added or team.member_removed event. The action’s grant stands in for edit access to the team, as for any edit, so adults may host a guest without managing the guests team freely:

JSON
"edits": [
  { "op": "add_member", "team": { "param": "stay" }, "user": { "param": "guest" },
    "expires_at": { "param": "until" } }
]

Code defines edits(ctx) and returns such a list with literal values. The definition names the object_types the code may create and the link_types it may use, and anything else is refused; membership edits may name only the team parameters’ teams:

JSON
"edits": { "object_types": ["work_order"], "link_types": ["orders"],
  "code": "def edits(ctx):\n    ..." }

Trashed objects. An object parameter with "state": "trashed" names an object in the trash rather than a live one, of a type that is not a team type:

JSON
"parameters": [{ "api_name": "document", "object_type": "document",
                 "state": "trashed", "required": true }],
"edits": [{ "op": "restore_object", "object": { "param": "document" } }]

Only restore_object and purge_object touch such an object, and other edits only after a restore_object; no edit touches an object after it is deleted or purged. A via path cannot start from it, and sets cannot target it. The definition is checked for this edit by edit when it is stored, and code edits again when they run. Granting an action that restores a type’s objects also lets its grantees list that type’s trash.

An application makes at most 100 edits.

Applying, step by step

One request, one transaction. The first step that fails ends it, and nothing is written:

  1. Authorization: the grant, view access, and for OAuth the scope. 403.
  2. Parameters: checked like properties, and filled inputs are set. A filled input sent by the request, or an input sent while its visible_when is not true, is refused too. 422 validation_failed, with paths like params.summary.
  3. Snapshot: the parameters’ objects, and the links between them, are read. Criteria and edits see this snapshot.
  4. Expected versions: if the request names expected_versions ({param: version or etag}, as the caller sees the object) and an object has moved on, 409 version_conflict. A change to a property the caller cannot see is not a move.
  5. Criteria, and the conditions of the inputs present: 422 criteria_failed, with paths criteria.<name> and params.<input>.
  6. Edits: the snapshot’s objects are locked, and their versions and the links between them are checked again, so the criteria held on exactly what commits. If one changed in the meantime, 409 version_conflict, at the parameter’s path or at links. Link writes lock both their objects, so the links cannot change between this check and the commit. The records via paths reach are read again too (409, path via). An edit that breaks its type’s rules is 422 (or 409 for a unique value), with paths like edits[0].properties.summary.
  7. Record: the audit row, the effects, and the events.

Criteria and edit code run in one sandbox run per request, 5 seconds in total. Without a sandbox, actions with code get 503 validators_unavailable; declarative actions are unaffected.

The result says what changed:

JSON
{
  "application_id": "p2m4k6j8h0g1f3d5s7a9q1w3",
  "action": "open_work_order",
  "action_version": 1,
  "applied_at": "2026-10-25T09:12:00Z",
  "applied_by": { "id": "h3jd8s0q2l5x7c1v9b4n6m8k", "kind": "human", "via": "rest" },
  "created": [{ "type": "work_order", "id": "w7o2r4d6e8r0x1c3v5b7n9m1", "version": 1, "ref": "order" }],
  "updated": [{ "type": "incident", "id": "o4b8j2e6c0t1q5w9r3y7u2i6", "version": 4 }],
  "links": [{ "op": "create", "link": "orders", "from": "o4b8j2e6c0t1q5w9r3y7u2i6", "to": "w7o2r4d6e8r0x1c3v5b7n9m1", "changed": true }],
  "memberships": [],
  "deleted": [],
  "restored": [],
  "purged": [],
  "effects": [{ "id": "e5f1x9q3z7k2m8n4b6v0c2l4", "kind": "webhook", "status": "pending" }],
  "replayed": false
}

deleted and purged list {type, id}, and restored lists {type, id, version}. memberships lists the membership edits as {op, team, user, expires_at, changed}, with op add or remove; changed is false for removing a membership there was none of. The result names objects by id only, so it never carries a property the caller may not see.

Retries

Send an idempotency_key (1-200 characters) and a retry is safe. The key names one application per organization, action, and caller:

  • the same key with the same parameters and expected versions returns the stored result with 200 and replayed: true, and applies nothing;
  • the same key with different ones is 409 idempotency_conflict;
  • two concurrent requests with one key wait for each other.

Keys are forgotten after 7 days; the audit row stays. Keys starting with schedule: are reserved for schedules (400).

Effects

Effects reach outside the database: a webhook, an email to a user of the organization or to an outside address, or a call of a connector tool. They are written to an outbox in the same transaction, so they exist exactly when the application commits, and a background job delivers them afterwards, at least once. Failed deliveries are retried after 30 seconds, doubling up to an hour, 10 attempts in all, then marked failed.

JSON
"effects": [
  { "kind": "webhook", "url": "https://hooks.acme.example/work-orders" },
  { "kind": "email", "to": { "param": "notify" },
    "subject": "Work order for {{object.incident.title}}",
    "text": "{{caller.id}} opened {{created.order}}: {{param.summary}}" }
]

Emails go through the configured email provider, to exactly one of:

  • to: a user id expression. The email goes to that user’s address while they are an active member of the organization.
  • to_address: an address outside the organization, such as a customer or a supplier. The expression must be a string parameter or property with the email rule, or a literal address, so an action can only write to addresses that were checked as addresses.
JSON
{ "kind": "email",
  "to_address": { "object": "deal", "property": "customer_email" },
  "subject": "Your invoice {{created.invoice.number}}",
  "text": "Dear {{object.deal.customer_name}}, ..." }

Templates may use {{param.x}}, {{object.p.x}}, {{created.r}} (a created object’s id), {{created.r.x}} (one of its properties, as written, including a sequence number), {{caller.id}}, and {{action}}. The subject is one line of up to 200 characters, the text up to 5,000.

Emails to outside addresses end with the line Sent for <organization> by Worklet. and are limited to 200 an hour per organization (rate limits). Over the limit, a delivery waits for room without using up one of its attempts.

Conditional effects. Any effect may carry when, a condition in the same syntax as criteria. The effect is enqueued only if it held on the snapshot the action applied to:

JSON
{ "kind": "email", "to": { "param": "approver" },
  "when": { "op": "gt", "left": { "param": "amount" }, "right": { "value": 1000 } },
  "subject": "Discount of {{param.amount}} needs your decision",
  "text": "{{param.reason}}" }

Webhooks must use https and a public address. A webhook is a POST of {delivery_id, action, action_version, application_id, applied_at, applied_by, params, result} with three headers:

Header
x-worklet-timestampwhen it was sent
x-worklet-signaturesha256=<hex HMAC-SHA256 of "<timestamp>.<body>">, keyed by the action’s webhook secret
x-worklet-deliverythe delivery id; the same on every retry

The same values also go out under the deprecated x-happy-enterprise-* names, for receivers written for earlier versions.

Owners read the secret at GET /v1/ontology/action-types/{type}/webhook-secret. Verify the signature, and deduplicate on the delivery id, since a delivery can arrive more than once.

Connector calls call a tool of an outside MCP server as the person who applied the action:

JSON
{ "kind": "connector", "tool": "git.comment",
  "arguments": { "repo": "api", "issue": "{{param.issue}}", "text": "Fixed in {{param.commit}}" } }

Applying checks that they may make this call, with these arguments, and refuses the application (403, or 422 for arguments their grant does not allow) otherwise. Delivery checks again, sends the effect’s id as an idempotency key, and never calls again once the remote has answered. See Connectors.

The audit log

Every committed application is recorded: the action and its version, who applied it and whether they are a human or an agent, the credential (a session, an OAuth grant with its client, an external JWT, an API token, or a schedule), whether it came through REST, MCP, or a schedule, the parameters, and the result. Refused applications leave no record beyond their response. The organization API’s database role may add to the log but not change or delete it.

Owners see every application at GET /v1/action-applications; everyone else sees their own. One application also shows each effect’s status, attempts, last error, and next attempt.

Each application also emits an action.applied event carrying its result, after the events of the objects it changed.

Schedules

Owners can schedule an action to be applied on a recurrence: a monthly check, a weekly chore, a nightly cleanup.

JSON
POST /v1/ontology/action-schedules
{ "api_name": "smoke_alarms",
  "description": "Test the smoke alarms every month",
  "action": "schedule_task",
  "run_as": "<caretaker agent>",
  "params": { "room": "<hall>", "title": "Test the smoke alarms" },
  "occurrence_param": "due_at",
  "every": 1, "unit": "month",
  "starts_at": "2026-11-01T09:00:00Z" }
  • When. Every 1 to 1,000 units, which are minute, hour, day, week, or month, from starts_at until ends_at, if set. Months are calendar months from starts_at, on its day of the month, or the month’s last day when it has fewer. Times are UTC.
  • As whom. Each occurrence is applied as run_as, an active agent of the organization. The agent needs the action’s grant like anyone else, and its permissions decide what the action reaches.
  • With what. params are fixed. occurrence_param, if set, names a timestamp parameter that receives each occurrence’s time, such as a chore’s due_at.
  • Once. The maintenance job applies each occurrence with the idempotency key schedule:<id>:<n>, so an occurrence applies at most once, even if the job stops halfway and runs it again. If occurrences were missed, because the schedule was paused or the server was down, only the latest due one is applied; the others are skipped.
  • Refusals. An occurrence that is refused (no grant, failed criteria, a deactivated agent, a deleted action) still counts as a run: it adds to the schedule’s failures and sets last_error, and the schedule goes on.

A schedule shows its progress: next_occurrence and next_at (null once it has ended), runs, failures, last_occurrence_at, last_run_at, last_application_id, and last_error. Its description, params, and enabled can change; its action, agent, and recurrence cannot, so delete it and create another. In the audit log, its applications are by the agent, with method and via both schedule and the schedule’s id. See the Actions API.

Versions

Action types are versioned like object types. A PUT with expected_version makes a new version, and every version is kept. Applications record the version they ran. DELETE removes the action from use; the audit log keeps referring to it.

See the Actions API for every endpoint.

Esc
↑ ↓ navigate↵ open