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
{
"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:
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_ontologyand 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, atparams.<name>), and edits that would take an object out of the caller’s sight are403. Afile_refparameter 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:applyscope, andontology:readfor 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;
nullclears 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:
{ "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:
{ "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, help | the input’s label (1-200 characters; also its description in the JSON Schema) and help text |
order | its place in the form |
control | text, textarea, number, date, datetime, boolean, enum, user, team, file, or, for object inputs only, object or objects |
picker | object inputs: {"filter": ...}, a query filter on the objects offered |
default | a prefill: {"value": ...}, {"target": true} (object inputs: the object the form was opened from), {"object", "property"}, or {"caller": "id"}. The server never applies it |
visible_when | a 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:
| Expression | Value |
|---|---|
{"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:
{ "op": "contains", "list": { "caller": "teams" }, "value": { "team": "maintainers" } }{ "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 anorof them.{"op": "listed", "in": <expression>}holds when the value, auser_ref, ateam_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 alisted_inread rule applies, so an action can allow exactly the people who read a record.
One more condition looks at links:
{ "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.
Following links
{"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:
{ "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:
notkeeps unknown unknown;andis false if any part is false,ortrue 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}:
linkslists the links between the parameters’ objects, of the link types the definition uses, as{link, from, to};teamsmaps team names to ids: every team when the definition has code, otherwise the teams it names;usersmaps user ids to{kind, teams}for the caller, the users thatuserexpressions name, theuser_refparameters, and theuser_refvalues of the parameters’ objects, at most 1000.presentlists the inputs that are present;viaholds the values of the definition’sviapaths, keyed liketarget.building.leads, unknown ones asNone. A code criterion lists the paths it reads in its ownvia, as{object, via, property}.
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:
{ "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:
| Edit | Does |
|---|---|
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:
"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:
"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:
"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:
- Authorization: the grant, view access, and for OAuth the scope.
403. - Parameters: checked like properties, and filled inputs are set. A filled input sent by the request, or an input sent while its
visible_whenis not true, is refused too.422 validation_failed, with paths likeparams.summary. - Snapshot: the parameters’ objects, and the links between them, are read. Criteria and edits see this snapshot.
- 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. - Criteria, and the conditions of the inputs present:
422 criteria_failed, with pathscriteria.<name>andparams.<input>. - 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 atlinks. Link writes lock both their objects, so the links cannot change between this check and the commit. The recordsviapaths reach are read again too (409, pathvia). An edit that breaks its type’s rules is422(or409for a unique value), with paths likeedits[0].properties.summary. - 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:
{
"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
200andreplayed: 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.
"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 theemailrule, or a literal address, so an action can only write to addresses that were checked as addresses.
{ "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:
{ "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-timestamp | when it was sent |
x-worklet-signature | sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">, keyed by the action’s webhook secret |
x-worklet-delivery | the 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:
{ "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.
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 areminute,hour,day,week, ormonth, fromstarts_atuntilends_at, if set. Months are calendar months fromstarts_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.
paramsare fixed.occurrence_param, if set, names a timestamp parameter that receives each occurrence’s time, such as a chore’sdue_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
failuresand setslast_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.