Get a Decision From Outside Octocom
Playbook: let a supplier, warehouse or external system make a decision from a link or an API call, then have the bot continue the conversation itself.
Use this playbook when someone outside Octocom has to decide something, and you want the bot to keep the customer conversation. The reviewer gets a link, opens the case and records the outcome without an Octocom login. The bot then reads the decision and replies to the customer.
The running example is a damaged-item review at Acme: the bot collects photos, a supplier decides in Slack whether to send a replacement, and the bot follows up with the customer. Copilot or an agent on the Octocom MCP can build all of it from this page.
Decisions can be made from anywhere
Slack is only the example. A review link works wherever your reviewers already work: a Google Sheets row where requests are recorded, an email, a ticketing tool or any other system. Systems can also record a decision with no page at all. See Send the link somewhere else and Get the decision from another system.
What you build
| Piece | Job |
|---|---|
| Request action | Registers the decision request, creates the link and notifies people |
| Slack message, or another destination | Puts the link in front of the reviewer |
| HTTP endpoint | Checks the link, loads the case and records the decision |
| Portal page | Shows the case and the decision form |
| Check action and workflow | Reads the outcome and tells the bot what to do with it |
How the bot and the decision card work together
The feature underneath is human decision requests, covered in full in Human-in-the-Loop Decisions. Every request appears in the Octocom dashboard as an internal Action requested card. The customer never sees it.
- The bot calls your request action, which registers the request. The action returns straight away. Python does not stay running while someone decides.
- The Action requested card appears on the conversation with the summary, the supporting information and the decision options.
- Following your workflow, the bot tells the customer the case was submitted and that it will follow up.
- A person decides, either on the card or through your link. Both record on the same request. The first decision wins and is final.
- The card shows the outcome, including extra details such as who reviewed the case.
- The bot gets a new turn telling it the request was resolved. It calls your check action and continues according to your workflow.
If nobody decides within timeout_hours (48 by default, 1 to 336 allowed), the request expires and the conversation is handed off to your team. While a request is pending, the conversation is not auto-closed for inactivity.
Recording a decision only records it. Sending the replacement, issuing a refund or anything else still belongs in your own actions and workflow.
When the bot continues
- From the dashboard card: the bot's turn starts immediately.
- From the API or
resolve_human_decisions: the decision saves immediately and the bot's turn starts within about a minute. A scheduled job picks it up, and retries if starting the turn fails. - If the customer writes in that short gap: their message cancels the scheduled turn and starts a normal one. The decision stays saved, but the bot is not told about it. This is why your workflow must check on every customer message while a review is open.
Build it
1. Create the request action
Create a custom Python action named requestDamageReview, with the arguments orderId and details. Describe details as one or two lines for the reviewer, with no customer personal details.
The action checks for an existing request first, so a repeated bot turn doesn't notify anyone twice. The link token is random, and only its SHA-256 hash is stored in the organization store.
import hashlib
import secrets
NAMESPACE = "damage-review-links"
REVIEW_PAGE_URL = "https://portal.example.com/review"
DECISIONS = [
{"key": "approved", "label": "Replacement approved"},
{
"key": "rejected",
"label": "Replacement rejected",
"input": {"label": "Reason", "type": "text", "required": True},
},
]
def customer_photo_ids(conversation):
return [
file["id"]
for message in conversation.get("messages", [])
if message.get("sender") == "customer"
for file in message.get("files", [])
if file.get("isSafe") and str(file.get("contentType", "")).startswith("image/")
]
def create_link(context, key, order_id, details, photo_ids):
token = secrets.token_urlsafe(32)
organization_store_upsert(
NAMESPACE,
hashlib.sha256(token.encode()).hexdigest(),
{
"conversationId": context["conversation"]["id"],
"key": key,
"orderId": order_id,
"details": details,
"photoIds": photo_ids,
},
)
return f"{REVIEW_PAGE_URL}#t={token}"
def slack_safe(text):
return text.replace("&", "&").replace("<", "<").replace(">", ">")
def notify(context, link, order_id, details, photo_ids):
text = "\n".join(
[
f"<@U012ABC> <@U034DEF> *Damaged item review: order {slack_safe(order_id)}*",
f"*Details:* {slack_safe(details)}",
f"*Photos:* {len(photo_ids)}",
f"<{link}|Open the case and record the outcome>",
]
)
webhook = get_secret("REVIEW_SLACK_WEBHOOK")
return send_slack_notification(webhook, text)["success"]
def execute_action(context):
order_id = str(context["args"]["orderId"]).strip()
details = str(context["args"]["details"]).strip()[:1000]
key = f"damage_review:{order_id}"
existing = get_human_decision_result(context, key)
if existing["status"] != "none":
return {**existing, "alreadySubmitted": True}
photo_ids = customer_photo_ids(context["conversation"])
review = request_human_decision(
context,
key=key,
summary=f"Damaged item review for order {order_id}",
decisions=DECISIONS,
info={"Order": order_id, "Details": details, "Photos": str(len(photo_ids))},
timeout_hours=72,
)
try:
link = create_link(context, key, order_id, details, photo_ids)
notified = notify(context, link, order_id, details, photo_ids)
except Exception as error:
print(f"Review notification failed: {type(error).__name__}")
notified = False
return {"status": review["status"], "notified": notified}The action returns notified so the workflow can hand off when the message didn't go out. Otherwise a request nobody saw would wait until its timeout. Never return the link itself: action results are stored with the conversation's tool calls.
2. Send the link in Slack
send_slack_notification posts to a channel through an Incoming Webhook. That page covers creating the webhook. Store the URL as an organization secret and read it with get_secret.
The text uses Slack's mrkdwn format:
- Bold:
*Damaged item review* - Mention a person:
<@U012ABC>, using their Slack member ID rather than their name - Link with a label:
<URL|Open the case>
The link looks like this:
https://portal.example.com/review#t=<token>The token sits after the #, so browsers don't send it when they load the page. It stays out of portal access logs and link previews. Only the page's own POST carries it, and endpoint request bodies are not stored in invocation history.
What an Incoming Webhook can't do:
- It doesn't return the message timestamp. A webhook can post text and blocks, but you can't automatically link to the message or reply in its thread. If reviewers want a thread link on the record, add a paste field to the form and save it as an extra input.
- It can't record a decision from a button. Buttons that act directly need a full Slack app with interactivity. A link to a review page is the simple path.
- The helper posts text only. For blocks, call the webhook yourself with
requests.post.
Don't put attachment links in the message. Privately stored attachment URLs expire after 1 hour, so the page requests fresh ones each time it opens.
3. Create the HTTP endpoint
Create a custom HTTP endpoint that allows POST only. Endpoint URLs have no built-in authentication, so the token check comes first.
The view action reads state and fresh attachment links. The decide action validates the form and records the decision. The conversation ID always comes from the stored case, never from the request body.
import hashlib
import requests
NAMESPACE = "damage-review-links"
DECISIONS = ("approved", "rejected")
REFUSALS = {
"already_resolved": "This case has already been decided.",
"expired": "This review has expired and the ticket went to the support team.",
"unanswered": "This ticket has another open review. Please answer them together in Octocom.",
}
def text_field(body, name, limit):
value = body.get(name)
return value.strip()[:limit] if isinstance(value, str) else ""
def load_case(token):
if not isinstance(token, str) or not 32 <= len(token) <= 128:
return None
digest = hashlib.sha256(token.encode()).hexdigest()
entry = organization_store_get(NAMESPACE, digest)
return entry["value"] if entry else None
def api_get(path, params=None):
return requests.get(
f"{OCTOCOM_API_BASE_URL}/rest/v1/{path}",
params=params,
headers={"X-API-Key": OCTOCOM_ORG_API_KEY},
timeout=10,
)
def view(case):
response = api_get(
"conversations/human-decision-requests",
{"conversationId": case["conversationId"], "key": case["key"]},
)
response.raise_for_status()
state = response.json()
photos = []
for file_id in case.get("photoIds", [])[:12]:
file_response = api_get(f"files/{file_id}")
if file_response.status_code == 200:
file = file_response.json()["data"]
photos.append({"name": file["name"], "url": file["url"]})
return {
"case": {"orderId": case["orderId"], "details": case["details"]},
"status": state["status"],
"decision": state.get("decision"),
"inputs": state.get("inputs"),
"photos": photos,
}
def decide(context, case, body):
decision = body.get("decision")
reviewer = text_field(body, "reviewer", 100)
reason = text_field(body, "reason", 2000)
if decision not in DECISIONS:
return http_response({"error": "Please choose an outcome."}, status=422)
if not reviewer:
return http_response({"error": "Please enter your name."}, status=422)
if decision == "rejected" and not reason:
return http_response({"error": "Please enter a reason."}, status=422)
inputs = {"Reviewed by": reviewer}
if reason:
inputs["Reason"] = reason
result = resolve_human_decisions(
context,
answers=[{"key": case["key"], "decision": decision, "inputs": inputs}],
conversation_id=case["conversationId"],
)
if not result["success"]:
code = result.get("code")
message = REFUSALS.get(code, "This outcome could not be saved.")
return http_response({"error": message, "code": code}, status=409)
return {"recorded": True}
def handle(context):
body = context["request"]["body"]
if not isinstance(body, dict):
return http_response({"error": "Invalid request"}, status=400)
case = load_case(body.get("token"))
if case is None:
return http_response({"error": "This review link is not valid."}, status=401)
if body.get("action") == "decide":
return decide(context, case, body)
return view(case)A refused decision comes back as {"success": False, "error": ..., "code": ...} and saves nothing:
| Code | Meaning |
|---|---|
invalid | The decision isn't offered by the request, or required text is missing |
not_found | The conversation or request key doesn't exist |
already_resolved | Someone already answered. Recording a decision is final |
expired | The request passed its timeout and the conversation was handed off |
unanswered | Another pending request needs an answer in the same call |
imported | Imported conversations can't be changed |
Extra input labels such as Reviewed by are stored and shown on the Action requested card. Input values must be strings. To let a reviewer send a case back to your team, add a third option such as hand_off to DECISIONS in both the action and the endpoint, and to the page's form and LABELS. Then have the workflow hand off when it reads that decision.
4. Create the portal page
Create a custom portal page at /review, as one self-contained HTML document. The customer portal must be enabled. With MCP, use save_portal_page, preview_portal_page and publish_portal_page, as described in Portal MCP tools.
The page shows the case first and records a decision only when the reviewer confirms. Opening a link must never record anything, because chat apps and email scanners load links automatically. After a decision, the page shows a read-only record.
Replace YOUR_ENDPOINT_URL with the URL generated for your endpoint, and set REVIEW_PAGE_URL in the request action to the page's published address. That URL can be public. Keep every credential out of this HTML.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<title>Damaged item review</title>
<style>
body {
margin: 0 auto;
max-width: 720px;
padding: 24px 16px;
font: 16px/1.5 system-ui, sans-serif;
}
label {
display: block;
margin-top: 12px;
}
input[type="text"],
textarea {
box-sizing: border-box;
width: 100%;
padding: 8px;
font: inherit;
}
button {
margin-top: 16px;
padding: 10px 18px;
font: inherit;
}
img {
max-width: 220px;
margin: 8px 8px 0 0;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<h1 id="title">Damaged item review</h1>
<p id="status">Loading the case...</p>
<dl id="facts"></dl>
<div id="photos"></div>
<form id="form" hidden>
<label>
<input type="radio" name="decision" value="approved" />
Replacement approved
</label>
<label>
<input type="radio" name="decision" value="rejected" />
Replacement rejected
</label>
<label for="reason">Reason (required when rejecting)</label>
<textarea id="reason" maxlength="2000"></textarea>
<label for="reviewer">Your name</label>
<input type="text" id="reviewer" maxlength="100" />
<p id="error" role="alert" hidden></p>
<button type="submit" id="submit">Save outcome</button>
</form>
<script>
const ENDPOINT = "YOUR_ENDPOINT_URL";
const LABELS = {
approved: "Replacement approved",
rejected: "Replacement rejected",
};
const token = new URLSearchParams(location.hash.slice(1)).get("t");
const el = (id) => document.getElementById(id);
async function call(payload) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, token }),
credentials: "omit",
});
const body = await response.json().catch(() => ({}));
return { ok: response.ok, body };
}
function showFacts(rows) {
el("facts").textContent = "";
rows.forEach(([label, value]) => {
if (!value) return;
const term = document.createElement("dt");
const detail = document.createElement("dd");
term.textContent = label;
detail.textContent = value;
el("facts").append(term, detail);
});
}
function showPhotos(photos) {
el("photos").textContent = "";
(photos || []).forEach((photo) => {
const link = document.createElement("a");
const image = document.createElement("img");
link.href = photo.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
image.src = photo.url;
image.alt = photo.name || "Customer photo";
link.append(image);
el("photos").append(link);
});
}
function statusText(data) {
if (data.status === "pending")
return "Review the case and record the outcome.";
if (data.status === "expired")
return "This review expired. The ticket went to the support team.";
return "Recorded: " + (LABELS[data.decision] || data.decision);
}
async function load() {
const { ok, body } = await call({ action: "view" });
if (!ok) {
el("status").textContent =
body.error || "This case could not be loaded. Please try again.";
el("form").hidden = true;
return;
}
el("title").textContent = "Order " + body.case.orderId;
el("status").textContent = statusText(body);
showFacts([
["Details", body.case.details],
...Object.entries(body.inputs || {}),
]);
showPhotos(body.photos);
el("form").hidden = body.status !== "pending";
}
el("form").addEventListener("submit", async (event) => {
event.preventDefault();
el("error").hidden = true;
el("submit").disabled = true;
try {
const choice = document.querySelector("input[name=decision]:checked");
const { ok, body } = await call({
action: "decide",
decision: choice ? choice.value : "",
reason: el("reason").value,
reviewer: el("reviewer").value,
});
if (
ok ||
body.code === "already_resolved" ||
body.code === "expired"
) {
await load().catch(() => {
el("form").hidden = true;
el("status").textContent =
"Reload this page to see the latest state.";
});
} else {
el("error").textContent =
body.error || "The outcome could not be saved. Please try again.";
el("error").hidden = false;
}
} catch {
el("error").textContent =
"The outcome could not be saved. Check your connection and try again.";
el("error").hidden = false;
} finally {
el("submit").disabled = false;
}
});
if (token) {
load().catch(() => {
el("status").textContent =
"This case could not be loaded. Check your connection and try again.";
});
} else {
el("status").textContent = "Open this page from your review link.";
}
</script>
</body>
</html>On the built-in <business-slug>.octocom.page address, the page runs in a CSP sandbox with no cookies or local storage, so read the token from location.hash and don't try to remember anything between visits. fetch with credentials: "omit" works against custom endpoints, and images from signed storage URLs load normally. Always set case details with textContent, because they include text that started with the customer.
5. Create the check action and workflow
Create checkDamageReview with the required argument orderId. It must build exactly the same key as the request action.
def execute_action(context):
order_id = str(context["args"]["orderId"]).strip()
return get_human_decision_result(context, key=f"damage_review:{order_id}")Enable both actions in a workflow, plus any action needed to carry out an approved outcome. Example instructions:
When a customer reports a damaged item, collect the order ID and photos, then call
requestDamageReview.If
notifiedis false, hand off to the team. Otherwise tell the customer the case was submitted and that you will follow up. Do not hand off while the review is open.When you are told the review was resolved, and whenever the customer writes while a review is open, call
checkDamageReviewbefore saying anything about it. Then act on itsstatus:
pending: say it is still under review. Do not guess an outcome or a timeline.resolvedwith decisionapproved: follow the replacement process. Confirm a replacement only after that action succeeds.resolvedwith decisionrejected: explain the result usinginputs.Reason, then offer what our policy allows.expired: the case went to our team. Say the team will follow up.none: no review was submitted yet. This is not a handoff. Collect what is missing and submit it.Do not mention the supplier to the customer. Do not promise anything the reviewer did not decide, such as whether the customer keeps the item.
Keep that last instruction, adapted to your business. In our testing, the bot invented exactly that kind of promise until it was told not to.
Send the link somewhere else
The action, endpoint, page and workflow stay the same. Only notify changes.
A Google Sheets row
If requests are already recorded in a sheet, add the link to the row with add_google_sheets_row. Share the sheet with the Octocom service account as an Editor.
def sheet_safe(text):
return text.lstrip("=+-@ ")
def notify(context, link, order_id, details, photo_ids):
result = add_google_sheets_row(
"YOUR_SHEET_ID",
{
"Order": sheet_safe(order_id),
"Details": sheet_safe(details),
"Photos": len(photo_ids),
"Review": f'=HYPERLINK("{link}", "Open review")',
},
sheet_name="Reviews",
preserve_headers=True,
)
return result["success"]- Always pass
preserve_headers=True. Without it, the row-1 headers are replaced by the keys you send, which removes any column your team added. - Values are read as if typed. That is what makes
HYPERLINKwork, though spreadsheets in some locales expect;between formula arguments, so check one row first. It also means text starting with=becomes a formula, so strip those characters from anything that came from a customer. - Everyone who can open the sheet can use the link. Share the sheet accordingly.
An email
Use send_transactional_email for a one-off message to a reviewer. It creates no conversation or ticket.
def notify(context, link, order_id, details, photo_ids):
result = send_transactional_email(
context,
{
"to": "reviews@example.com",
"subject": f"Damaged item review: order {order_id}",
"text": f"{details}\n\nOpen the case and record the outcome: {link}",
"reply_to": "support@example.com",
},
)
return result["success"]Keep the link in the body: the send is recorded on the conversation timeline with its recipient and subject. Each organization can send 60 of these per hour and 300 per day, and nothing is sent in test conversations.
Use send_outbound_email instead when your team should see the email and any replies in the help desk. It takes the same to, subject and text, needs an email integration on the business, and creates a separate conversation that is already handed off. A reply to that email doesn't record a decision. The reviewer still uses the link.
Any other system
Anything your Python can reach can carry the link. Call the system's API with requests.post, or with proxy_request if it allowlists by IP. Return notified: False if the call fails.
Get the decision from another system
A decision doesn't need a page. Pick the option that matches how the other system works:
| The other system | Use |
|---|---|
| Can call an API and hold an Octocom API key | The REST API |
| Sends signed webhooks, or shouldn't hold a key | An HTTP endpoint |
| Is a spreadsheet people type verdicts into | A recurring job |
| Only offers an API you can poll | A recurring job that polls it and calls resolve_human_decisions |
resolve_human_decisions works in HTTP endpoints, recurring jobs, event handlers and sidebar actions. Bot actions cannot call it, so the bot cannot use it to approve its own request.
Call the REST API directly
Send the conversation ID and request key to the other system when you create the request. It then calls POST /rest/v1/conversations/human-decision-requests/resolve with a key from Settings -> API keys:
curl -X POST "https://api.octocom.ai/rest/v1/conversations/human-decision-requests/resolve" \
-H "X-API-Key: $OCTOCOM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"conversationId": "CONVERSATION_ID",
"answers": [
{
"key": "damage_review:1001",
"decision": "rejected",
"inputs": { "Reason": "Damage is not covered", "Reviewed by": "Warehouse system" }
}
]
}'A success returns {"success": true, "conversationId": ...}. A refusal returns status 400, 404 or 409 with the same code values. API keys cover your whole organization, so keep them server-side and never put one in a page.
Receive a callback on an HTTP endpoint
Let the other system call a custom HTTP endpoint when it shouldn't hold an Octocom key. Give it a case ID when you create the request, and keep the matching conversation in the organization store. Verify the sender before anything else. The signature header and format below are placeholders, so follow the sender's protocol.
import base64
import hashlib
import hmac
def handle(context):
request = context["request"]
expected = hmac.new(
get_secret("WAREHOUSE_WEBHOOK_SECRET").encode(),
base64.b64decode(request["rawBodyBase64"]),
hashlib.sha256,
).hexdigest()
supplied = request["headers"].get("x-signature")
if not isinstance(supplied, str) or not hmac.compare_digest(supplied, expected):
return http_response({"error": "Unauthorized"}, status=401)
body = request["body"]
entry = organization_store_get("damage-review-cases", str(body.get("caseId")))
if entry is None:
return http_response({"error": "Unknown case"}, status=404)
inputs = {"Reviewed by": "Warehouse system"}
if body.get("reason"):
inputs["Reason"] = str(body["reason"])
result = resolve_human_decisions(
context,
answers=[
{
"key": entry["value"]["key"],
"decision": str(body.get("decision")),
"inputs": inputs,
}
],
conversation_id=entry["value"]["conversationId"],
)
if not result["success"] and result.get("code") != "already_resolved":
return http_response(result, status=409)
return {"recorded": True}Treating already_resolved as a success stops the sender retrying a webhook it already delivered.
Read a verdict column from a sheet
Reviewers type a verdict into the row, and a recurring job records it. There is no page and no link, and edit access to the sheet is what lets someone decide.
In the request action, write a random case ID to the row and keep the conversation in the store. Rows only hold the case ID, so editing a cell can't point the job at another conversation.
def send_to_sheet(context, key, order_id):
case_id = "case_" + secrets.token_hex(16)
organization_store_upsert(
"damage-review-cases",
case_id,
{"conversationId": context["conversation"]["id"], "key": key},
)
result = add_google_sheets_row(
"YOUR_SHEET_ID",
{"Case": case_id, "Order": order_id.lstrip("=+-@ "), "Status": "Waiting"},
sheet_name="Reviews",
preserve_headers=True,
)
return result["success"]Replace the two lines in the action's try block with notified = send_to_sheet(context, key, order_id), so a failed write still reaches the workflow's handoff.
Add Verdict, Reason and Reviewer columns for people to fill in, then run this job every 5 minutes, the shortest schedule allowed:
SHEET_ID = "YOUR_SHEET_ID"
CASES = "damage-review-cases"
VERDICTS = {"Approved": "approved", "Rejected": "rejected"}
def record(context, row):
values = row["values"]
decision = VERDICTS.get(values.get("Verdict"))
case_id = values.get("Case")
if not decision or not case_id:
return False
entry = organization_store_get(CASES, case_id)
if entry is None:
return False
inputs = {"Reviewed by": values.get("Reviewer") or "Review sheet"}
if values.get("Reason"):
inputs["Reason"] = values["Reason"]
result = resolve_human_decisions(
context,
answers=[{"key": entry["value"]["key"], "decision": decision, "inputs": inputs}],
conversation_id=entry["value"]["conversationId"],
)
update_google_sheets_row(
SHEET_ID,
{"Status": "Recorded" if result["success"] else f"Refused: {result.get('code')}"},
row_number=row["rowNumber"],
expected={"Case": case_id, "Status": "Waiting"},
sheet_name="Reviews",
)
return result["success"]
def run_job(context):
recorded = 0
cursor = None
while True:
page = list_google_sheets_rows(
SHEET_ID,
sheet_name="Reviews",
where={"Status": "Waiting"},
limit=100,
cursor=cursor,
)
for row in page["rows"]:
if record(context, row):
recorded += 1
cursor = page["nextCursor"]
if cursor is None:
return {"recorded": recorded}Use a dropdown on the Verdict column so the values match exactly. Refused: invalid usually means a rejection without a reason: fill in the reason and set Status back to Waiting. Direct edits can take up to 60 seconds to appear in reads, and the bot's turn starts within about a minute of recording.
Plan for these behaviors
- Answer all pending requests together. If a conversation has another pending request, the call returns
unansweredand saves nothing. A single-case link can't answer both, so keep to one external review per conversation where you can. The dashboard stacks several cards and submits them together. - Keep the ticket with the bot while waiting. The bot doesn't reply on a handed-off email conversation, or on other non-web channels, even after a decision arrives. On web chat, your post-handoff reply setting applies. A decision does not undo a handoff.
- Handle the endpoint limits. Endpoints allow 10 requests per minute with a burst of 5, and 2 concurrent runs. Over the limit they return
429, and the page above shows an error so the reviewer can try again. See Limits and retries. - Let the team still decide. The Action requested card stays answerable in the dashboard, which helps if the reviewer is unavailable. Whoever answers first wins.
Security and retention
- The link is the access boundary. It has no login, so anyone holding it can decide. Share it only with the intended people, for example in a private Slack channel.
- A reviewer name is self-declared. It's useful for the record, but it isn't proof of identity. If you need proof, have an authenticated system call the REST API.
- Store only the token's hash. A leaked store entry then can't be turned back into a working link.
- Never trust a conversation ID from a request. Always read it from the stored case.
- Links stay viewable unless you design otherwise. Customer photos may contain personal details, so stop serving case details and attachments some days after the decision or expiry.
The simplest way to do that is to delete old entries, which also keeps the namespace under its limit of 2,500 entries. A deleted entry makes the link return 401, so the page stops serving the case. Privately stored attachment URLs already issued expire within the hour. Other attachments, such as those received by email, have permanent URLs that deleting the entry doesn't revoke, so treat any URL the page has shown as sensitive.
Run this job daily, and keep the retention longer than timeout_hours. If you also use damage-review-cases, run it for that namespace too.
from datetime import datetime, timedelta, timezone
NAMESPACE = "damage-review-links"
RETENTION_DAYS = 14
def is_old(entry, cutoff):
created = datetime.fromisoformat(entry["createdAt"].replace("Z", "+00:00"))
return created < cutoff
def run_job(context):
cutoff = datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)
deleted = 0
while True:
page = organization_store_list(
NAMESPACE, limit=100, order_by="createdAt", direction="asc"
)
old = [entry for entry in page["entries"] if is_old(entry, cutoff)]
for entry in old:
organization_store_delete(NAMESPACE, entry["key"])
deleted += len(old)
if len(old) < len(page["entries"]) or page["nextCursor"] is None:
return {"deleted": deleted}Test before launch
Custom actions run for real in test conversations, including MCP ones, so a test can ping real reviewers. Use a staging business that returns the link instead of notifying:
if context["business"]["slug"] == "acme-staging":
return {"status": review["status"], "notified": True, "testReviewUrl": link}Put this right after create_link, and only ever return the link on staging. Test with a real conversation. The action reads context["conversation"], and without a conversation request_human_decision creates nothing.
Then check:
- Opening the link records nothing, and an unknown token returns
401. - A rejection without a reason is refused, and a valid decision shows a read-only record on the page.
- The Action requested card shows the outcome and the reviewer's name.
- The bot reads the result within about a minute and replies correctly for every decision.
- A customer message sent after the decision, before the bot's turn, still makes the bot call the check action.
- With
timeout_hours=1, the request expires, the conversation is handed off, and the page shows the expired state. - A failed notification hands off instead of waiting.
MCP test messages can't carry attachments, so check the photo path with a real message that has one attached. Before launch, send one real notification marked TEST to check that mentions and formatting look right.
Related
- Human-in-the-Loop Decisions: the feature and helper reference
- Custom HTTP Endpoints: limits, responses and debugging
- Custom pages: paths, publishing and the sandbox
- Organization Store: limits and pagination
- Google Sheets: tables, ranges and reliability: access and timeout recovery