Outcome
Approve the exact pending rendered message for the sender runner.
Before you start
- Verify the environment, credential, authorized organization and the published action permission workspace:write.
- Discover an accessible Engage workspace and bind resourceType=workspace and its resourceId separately from action input. Fixture identifiers grant no access.
- Approve the exact mutation and its effects before CLI --confirm or MCP confirm:true. This documentation does not authorize outreach.
Discover the target
- Read approvals.list in the intended authorized Engage workspace. Select messages[].id and retain that row’s exact subject, text, version, campaignId, enrollmentId, stepId, batchDate and scheduledFor.
- Read campaigns.policy.get using the discovered campaignId to inspect the current policy and workspace stop. Bind workspace identity separately from action input.
topolo whoami --json
topolo actions get app_topolo_engage.approvals.approve --json
topolo workspaces list topolo-engage --json
Input fields
| Field | Required | Meaning and constraints |
|---|---|---|
id | Yes | Required messages[].id returned by approvals.list; approve the exact reviewed pending version. |
Validate, plan and execute
Set ENGAGE_WORKSPACE_ID to the identifier of your selected workspace. Prepare payload.json from the current input schema and discovered identifiers. Use the same payload for validation, planning and execution.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions validate app_topolo_engage.approvals.approve --data @payload.json --json
topolo actions plan app_topolo_engage.approvals.approve --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json
Review the plan and obtain approval for the exact effects before executing the unchanged request.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions call app_topolo_engage.approvals.approve --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json
For MCP, use topolo_get_action, topolo_validate_action, topolo_plan_action and topolo_call_action. Pass the selected resource context separately; confirm:true is required for mutations.
Worked requests and responses
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Quick question, Ada",
"text": "Hi Ada,\n\nHow is Analytical handling outreach?\n\nAsh\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-06",
"approvedBy": "user_1",
"approvedAt": "2026-10-06T08:40:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T08:40:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: message approval > the runner never sends an unapproved message: it parks it for approval, then sends it once approved. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Quick question, Ada",
"text": "Hi Ada,\n\nHow is Analytical handling outreach?\n\nAsh\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-06",
"approvedBy": "user_1",
"approvedAt": "2026-10-06T08:30:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T08:30:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: message approval > a policy change reaches only work that is not approved yet. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_legacy",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Hi Ada",
"text": "Hello.\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 0,
"batchDate": "2026-10-06",
"approvedBy": "user_1",
"approvedAt": "2026-10-06T08:30:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T08:30:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: message approval > a message rendered after a change is approved under the new version; an unstored version falls back to the current one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_3"
}
Actual local response
{
"message": {
"id": "eng_msg_3",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 3,
"previousMessageId": "eng_msg_2",
"subject": "New subject",
"text": "Final text.",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-06",
"approvedBy": "user_1",
"approvedAt": "2026-10-06T08:30:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T08:30:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: message approval > request_edit creates a new immutable version with its own idempotency key and keeps the previous one unchanged. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Quick question, Ada",
"text": "Hi Ada,\n\nHow is Analytical handling outreach?\n\nAsh\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-05",
"approvedBy": "user_1",
"approvedAt": "2026-10-05T15:00:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T07:00:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: render ahead for approval > queues a message due tomorrow today, sends it in the window once approved, and never renders twice. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Quick question, Ada",
"text": "Hi Ada,\n\nHow is Analytical handling outreach?\n\nAsh\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-05",
"approvedBy": "user_1",
"approvedAt": "2026-10-06T08:00:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T08:00:00.000Z",
"footerSource": "manual",
"brandKitRef": null,
"brandVersionRef": null
}
}
Source test: render ahead for approval > approval after the send time has passed makes the message due at once. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 200
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"message": {
"id": "eng_msg_1",
"campaignId": "eng_cmp_1",
"enrollmentId": "eng_enr_1",
"stepId": "eng_stp_1",
"stepPosition": 1,
"version": 1,
"previousMessageId": null,
"subject": "Quick question, Ada",
"text": "Hi Ada,\n\nHow is Analytical handling outreach?\n\nAsh\n\n--\nBrand v1 opt-out.\nBrand Ltd\n1 Old Road",
"status": "scheduled",
"approvalState": "approved",
"policyVersion": 1,
"batchDate": "2026-10-05",
"approvedBy": "user_1",
"approvedAt": "2026-10-05T09:00:00.000Z",
"approvalNote": null,
"scheduledFor": "2026-10-06T07:00:00.000Z",
"footerSource": "brand",
"brandKitRef": "bk_1",
"brandVersionRef": "bkv_1"
}
}
Source test: runner with a footer connected to Brand > keeps the footer and version of a message rendered ahead and approved before the kit changed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Effects and response meaning
- Records approved state, actor, time and the campaign’s current policy version for this message. The rendered content is retained unchanged.
- This approval can enable an external send when the runner evaluates the message and all other holds pass. It is due at the scheduled time, or at approval time if already overdue; approval does not send an ahead-of-time message early.
- Emergency stop, seat permissions, health, caps, send window and eligibility still apply. The approval response is not provider delivery proof.
Verify the result
- Read enrollments.messages.list with id=the retained enrollmentId in the same workspace. Match the specific messages[].id and inspect approvalState, status, holdReason, scheduledFor, sentAt and transportMessageId.
- A message leaving approvals.list only proves it is no longer pending; it does not by itself establish approval, delivery, rejection or deletion.
- Compare the response message.id and its approved state with the reviewed pending row. Inspect retained history before interpreting a later sent state.
Recover
After an uncertain approval, read the retained enrollment’s message history before another decision. If approvalState is approved or the message has already been sent, do not repeat approval. No undo-approval or recall action is declared. An emergency stop or enrollment stop is a separate reviewed operation and cannot recall a completed send.
Refused responses
Captured approvals.approve · HTTP 409
Action input
{
"id": "eng_msg_1"
}
Actual local response
{
"error": {
"code": "state_conflict",
"message": "This message is not waiting for approval."
}
}
Source test: message approval > the runner never sends an unapproved message: it parks it for approval, then sends it once approved. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured approvals.approve · HTTP 404
Action input
{
"id": "eng_msg_9"
}
Actual local response
{
"error": {
"code": "not_found",
"message": "Message was not found in this workspace."
}
}
Source test: message approval > 404s an unknown message and 409s a lost approval race. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Error recovery
- HTTP 403
permission_denied: Stop and inspect the action permission in the current credential; do not broaden permissions or switch contexts automatically. - HTTP 422
invalid_action_input: Correct the input against the current schema and validate again before planning. - HTTP 409
state_conflict: Read current message history and campaign policy. A message must still be scheduled and pending_approval; batch approval also requires the per_batch policy. Never loop on an already-applied or invalid decision. - HTTP 404
not_found: Rediscover the target using approvals.list and retained enrollment message history in the same workspace. Batch approval may instead refer to an unknown campaign; verify it with campaigns.get.
Related actions
Evidence and limits
-
contracts/autonomy.mjs -
contracts/sending.mjs -
apps/api/src/autonomy/approvals-handlers.ts -
apps/api/src/autonomy/approvals-repository.ts -
apps/api/src/autonomy/policy-service.ts -
apps/api/test/autonomy.test.ts -
Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
-
Widget/bootstrap use actual app handler but mocked worker runtime and app identity.
-
No gateway execution, live permissions, external delivery or deployed proof.
The examples are local route captures with synthetic context and simulated providers. They do not prove live authorization, CLI/MCP gateway execution, production state or delivery.