Outcome
SEND a plain-text reply to the contact from the thread's seat mailbox (delivery class outreach), threaded on the last inbound reply (In-Reply-To, References). The compliance footer is appended only when the workspace setting appendFooterToReplies is on. Refused with 409 while the workspace or campaign emergency stop is on, the seat is paused or has no mailbox, or the thread is classified unsubscribe. Records the outbound message, marks the thread responded and clears its SLA. Audited.
Before you start
- Verify the intended environment, signed-in credential, authorized organization and the action permission workspace:write.
- Discover and select an authorized canonical Engage workspace. Pass resourceType=workspace and resourceId separately from action input.
- Review the exact effects and obtain approval before CLI --confirm or MCP confirm:true. A local capture does not authorize a live mutation.
Discover the target
topolo whoami --json
topolo actions get app_topolo_engage.threads.reply --json
topolo workspaces list topolo-engage --json
Set ENGAGE_WORKSPACE_ID to the identifier of the selected authorized workspace.
Input contract
| Field | Required | Declared constraints |
|---|---|---|
id | Yes | {"type":"string","minLength":1,"maxLength":128} |
text | Yes | {"type":"string","minLength":1,"maxLength":20000} |
Validate, plan and execute
Build payload.json from the current schema and discovered identifiers. The action description above defines its behavior, limits, side effects and declared errors.
: "${ENGAGE_WORKSPACE_ID:?Set ENGAGE_WORKSPACE_ID to your selected workspace identifier}"
topolo actions validate app_topolo_engage.threads.reply --data @payload.json --json
topolo actions plan app_topolo_engage.threads.reply --resource-type workspace --resource-id "$ENGAGE_WORKSPACE_ID" --data @payload.json --confirm --json
Review the plan and approve its 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.threads.reply --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 resource context separately where required; confirm:true is required for mutations.
Captured successful requests and responses
Captured HTTP 200 · inbound replies thread into the inbox > continues the thread when the contact answers one of our human replies
Action input
{
"id": "eng_thr_1",
"text": "Happy to help."
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-141c82d46b02d85dd2d32b0abd4ef764@engage.topolo.invalid>",
"nexusMessageId": "email_stub_141c82d46b02d85dd2d32b0abd4ef764",
"snippet": "Happy to help.",
"bodyText": "Happy to help.",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: inbound replies thread into the inbox > continues the thread when the contact answers one of our human replies. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > sends a threaded plain-text reply from the seat mailbox, records it and clears the SLA
Action input
{
"id": "eng_thr_1",
"text": "Thanks Ada — Tuesday works."
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T09:00:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T09:00:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-141c82d46b02d85dd2d32b0abd4ef764@engage.topolo.invalid>",
"nexusMessageId": "email_stub_141c82d46b02d85dd2d32b0abd4ef764",
"snippet": "Thanks Ada — Tuesday works.",
"bodyText": "Thanks Ada — Tuesday works.",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T09:00:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > sends a threaded plain-text reply from the seat mailbox, records it and clears the SLA. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > derives the idempotency key from organization / workspace / thread / reply number
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-141c82d46b02d85dd2d32b0abd4ef764@engage.topolo.invalid>",
"nexusMessageId": "email_stub_141c82d46b02d85dd2d32b0abd4ef764",
"snippet": "Hi",
"bodyText": "Hi",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > derives the idempotency key from organization / workspace / thread / reply number. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > omits threading headers it has nothing for and uses a generic subject
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": null,
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-141c82d46b02d85dd2d32b0abd4ef764@engage.topolo.invalid>",
"nexusMessageId": "email_stub_141c82d46b02d85dd2d32b0abd4ef764",
"snippet": "Hi",
"bodyText": "Hi",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > omits threading headers it has nothing for and uses a generic subject. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > appends the effective footer only when the workspace asks, and refuses an incomplete one
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-141c82d46b02d85dd2d32b0abd4ef764@engage.topolo.invalid>",
"nexusMessageId": "email_stub_141c82d46b02d85dd2d32b0abd4ef764",
"snippet": "Hi -- Test opt-out sentence for fixtures. Example Ltd 1 Example Street London",
"bodyText": "Hi\n\n--\nTest opt-out sentence for fixtures.\nExample Ltd\n1 Example Street\nLondon",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > appends the effective footer only when the workspace asks, and refuses an incomplete one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > records nothing new for a replayed send without an RFC id
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_2",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": null,
"nexusMessageId": "email_replayed",
"snippet": "Hi",
"bodyText": "Hi",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > records nothing new for a replayed send without an RFC id. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > sends a threaded plain-text reply from the seat mailbox, records it and clears the SLA
Action input
{
"id": "eng_thr_1",
"text": "One more thing."
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "Quick question, Ada",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T09:00:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T10:00:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_3",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-19190f87135e191df98a91bfd179518a@engage.topolo.invalid>",
"nexusMessageId": "email_stub_19190f87135e191df98a91bfd179518a",
"snippet": "One more thing.",
"bodyText": "One more thing.",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T10:00:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > sends a threaded plain-text reply from the seat mailbox, records it and clears the SLA. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 200 · reply inbox actions > threads.reply > omits threading headers it has nothing for and uses a generic subject
Action input
{
"id": "eng_thr_1",
"text": "Again"
}
Actual local response
{
"thread": {
"id": "eng_thr_1",
"enrollmentId": "eng_enr_1",
"contactId": "contact_1",
"seatId": "eng_seat_1",
"campaignId": "eng_cmp_1",
"subject": "RE: earlier",
"status": "waiting",
"intent": "unclassified",
"ownerUserId": null,
"firstReplyAt": "2026-10-06T08:30:00.000Z",
"lastReplyAt": "2026-10-06T08:30:00.000Z",
"slaDueAt": null,
"respondedAt": "2026-10-06T08:30:00.000Z",
"closedAt": null,
"followUpOn": null,
"referredContactId": null,
"lastSnippet": "Sounds interesting",
"createdAt": "2026-10-06T08:30:00.000Z",
"updatedAt": "2026-10-06T08:30:00.000Z",
"overdue": false
},
"message": {
"id": "eng_thm_3",
"threadId": "eng_thr_1",
"direction": "outbound",
"rfcMessageId": "<stub-19190f87135e191df98a91bfd179518a@engage.topolo.invalid>",
"nexusMessageId": "email_stub_19190f87135e191df98a91bfd179518a",
"snippet": "Again",
"bodyText": "Again",
"autoReply": false,
"receivedAt": null,
"sentAt": "2026-10-06T08:30:00.000Z",
"sentBy": "user_1"
}
}
Source test: reply inbox actions > threads.reply > omits threading headers it has nothing for and uses a generic subject. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Effects and response meaning
- SEND a plain-text reply to the contact from the thread's seat mailbox (delivery class outreach), threaded on the last inbound reply (In-Reply-To, References). The compliance footer is appended only when the workspace setting appendFooterToReplies is on. Refused with 409 while the workspace or campaign emergency stop is on, the seat is paused or has no mailbox, or the thread is classified unsubscribe. Records the outbound message, marks the thread responded and clears its SLA. Audited.
Verify the outcome
- Check the response against the declared output schema and the requested operation. For mutations, use the action-specific read described in the action description before any dependent operation.
Recover
For an uncertain result, inspect current retained state with a read action before retrying. Do not replay a mutation unless its contract explicitly guarantees idempotency.
Refused requests
Captured HTTP 409 · reply inbox actions > threads.reply > appends the effective footer only when the workspace asks, and refuses an incomplete one
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "compliance_footer_incomplete",
"message": "The compliance footer is incomplete, and this workspace appends it to replies."
}
}
Source test: reply inbox actions > threads.reply > appends the effective footer only when the workspace asks, and refuses an incomplete one. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "emergency_stop",
"message": "Sending is stopped by an emergency stop; resume it before replying."
}
}
Source test: reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "seat_paused",
"message": "The seat is paused; resume it before replying."
}
}
Source test: reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "mailbox_not_connected",
"message": "The seat has no connected mailbox."
}
}
Source test: reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "contact_unavailable",
"message": "The contact is archived or no longer exists."
}
}
Source test: reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "contact_unsubscribed",
"message": "The contact asked not to be contacted; no reply can be sent."
}
}
Source test: reply inbox actions > threads.reply > refuses with 409 while stopped, the seat is paused or has no mailbox, the contact is gone or unsubscribed. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 502 · reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "transport_error",
"message": "The reply could not be sent. Try again.",
"details": {
"message": "socket hang up"
}
}
}
Source test: reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 409 · reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "transport_refused",
"message": "The mailbox refused the reply.",
"details": {
"code": "mailbox_suspended"
}
}
}
Source test: reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 503 · reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "transport_unavailable",
"message": "Email sending is not available right now.",
"details": {
"reason": "transport_unavailable"
}
}
}
Source test: reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Captured HTTP 502 · reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message
Action input
{
"id": "eng_thr_1",
"text": "Hi"
}
Actual local response
{
"error": {
"code": "transport_error",
"message": "The reply could not be sent. Try again.",
"details": {
"message": "plain failure"
}
}
}
Source test: reply inbox actions > threads.reply > reports transport errors, refusals and an unavailable transport without recording a message. Actual route handlers with SQLite repositories; synthetic identity/workspace grants and provider ports.
Error recovery
- HTTP 409
compliance_footer_incomplete: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 409
emergency_stop: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 409
seat_paused: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 409
mailbox_not_connected: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 409
contact_unavailable: Preserve the request and inspect the declared dependency state. Retry only after the transient or configuration cause is resolved; do not infer that the operation completed. - HTTP 409
contact_unsubscribed: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 502
transport_error: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 409
transport_refused: Read the refusal details and current action contract, correct the stated cause, then validate and plan again. Do not retry unchanged or infer success. - HTTP 503
transport_unavailable: Preserve the request and inspect the declared dependency state. Retry only after the transient or configuration cause is resolved; do not infer that the operation completed.
Related actions
- List reply threads
- Get reply thread
- Classify reply intent
- Assign reply thread
- Close reply thread
- Reopen reply thread
- Hand off reply thread
Evidence and limits
-
/api/threads/{id}/reply -
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.
These are local route captures with synthetic identity and simulated dependencies. They do not prove live authorization, gateway execution, production state, or external delivery.