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 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.
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 |
{"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).
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.
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}:
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.
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 |
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 |
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 ..." }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.
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:
422 criteria_failed. - 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. 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": [],
"effects": [{ "id": "e5f1x9q3z7k2m8n4b6v0c2l4", "kind": "webhook", "status": "pending" }],
"replayed": false
}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 Happy Enterprise. 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-happy-enterprise-timestamp | when it was sent |
x-happy-enterprise-signature | sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">, keyed by the action’s webhook secret |
x-happy-enterprise-delivery | the delivery id; the same on every retry |
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.