#Agent actions and action requests
Status: Planned. Action requests (
lead.action_requested) and the reply to them (ack) are not implemented on the platform. Today, agents write to our CRM directly. This page describes the intent; the action parameters are preliminary. No dates are promised.
Several AI agents work in the platform's CRM. They do not just read leads, they change them: they set a score, move a deal, assign tasks, and send reminders to a customer. When you have an external CRM and it is the source of truth (the external mode, after a lead is accepted), agents must not change a lead silently. Instead, the agent files an action request; you receive it by webhook and decide: carry it out or decline.
Access: the lead.action_requested webhook (an integration subscription, webhooks:manage); leads:write to reply. Paid: no. PII: the event payload is thin; the reason and evidence are requested through the API unless the subscription has the include_pii flag enabled (PII transfer).
#Who the agents are
| Agent | What it does with a lead |
|---|---|
| AI seller | Conducts the conversation, creates a lead, sets a reminder (an "AI" type task), asks to bring in an operator |
| CRM linker | After each reply from the seller, refines the contact data (email, phone, tags), links or creates a deal, sets the intent and the lead's extracted data, rescores |
| Automations (tenant rules) | Move a deal through the funnel, set tags, score a lead, assign an operator, set priority, close a conversation |
| Nudge | Reminds a cooled-off lead or a customer who did not answer an offer |
| Strategist | Only reads funnel counters, writes nothing |
#How it works
The agent decides on an action
|
v
internal mode ----------> the action runs on our side, lead.updated event
both mode --------------> runs on our side, fields per the ownership table
external mode, accepted -> action request (lead.action_requested)
|
v
Your CRM: ack = accept -> the action runs, lead.updated event
ack = reject -> the action does not run, the agent takes the refusal into account
no reply in time -> the request expires (expired)
Before a lead is accepted (the external mode), actions run on our side, as in the internal mode: the lead is still ours.
#Request states
| State | Meaning |
|---|---|
requested | The request is created, no reply yet |
acked | Accepted by your CRM |
rejected | Declined by your CRM |
expired | The time ran out without a reply, or the tenant switched the mode from external to internal (CRM: modes) |
#Action types
| Type | What the agent asks for |
|---|---|
| Change status | Move the lead to a canonical status (for example, qualified) or mark a loss with a reason |
| Assign | Assign a responsible staff member |
| Create a task | Create a task on the lead |
| Send a nudge | Send a reminder to the customer |
| Hand off to an operator | Bring a human into the conversation |
A notification about the handoff of the conversation to an operator itself arrives as a separate event, conversation.handoff_requested (with lead_id if the conversation has a lead); there is no lead.handoff_requested event.
Every request carries a reason (why the agent decided so) and evidence (what it relied on: a phrase from the conversation, the length of silence, a score), so that a person in your CRM can make a well-informed decision.
#Request object
A preliminary schema; the final one is fixed at release.
| Field | Type | Description |
|---|---|---|
action_id | string (uuid) | The request ID |
lead_id | string (uuid) | The lead |
lead_version | integer | The lead's version at the time of the request |
type | string | The action type (the table above) |
params | object | The action parameters |
reason | string | The reason |
evidence | array | Evidence: short descriptions and links to the source |
agent | string | Which agent is asking |
state | string | requested, acked, rejected, expired |
requested_at, expires_at | string (ISO 8601), UTC | Created, and the reply deadline |
params examples:
| Type | Parameters |
|---|---|
| Change status | {"status": "qualified"} or {"status": "lost", "lost_reason": "..."} |
| Assign | {"assignee_id": "<staff member uuid>"} |
| Create a task | {"title": "...", "due_at": "..."} |
| Send a nudge | {"channel": "telegram", "text": "..."} |
| Hand off to an operator | {} |
#The lead.action_requested event
It arrives at the subscribed webhook address in the v2 envelope. By default the payload is "thin" (ID, version, type).
{
"id": "evt_EXAMPLE02",
"type": "lead.action_requested",
"api_version": "2026-10-01",
"created_at": "2026-10-11T10:02:41Z",
"livemode": true,
"data": {
"lead_id": "5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b",
"action_id": "a3d2f1c0-8b7e-4c55-9d2a-6e1f0b9c7d34",
"lead_version": 4
}
}
The signature in the Webhook-Signature header (JWS, Ed25519), delivery retries, and deduplication by Webhook-Event-Id are in the webhooks section. Get the content of the request with GET /leads/{id} or, with include_pii: true, right in the event; a separate endpoint for reading a request is not named in the design (see "Open questions").
#POST /leads/{id}/actions/{action_id}/ack
A reply to a request: accept or decline.
Scope: leads:write. Headers: Idempotency-Key (recommended), Content-Type: application/json.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
decision | string | yes | accept or reject | "accept" |
reason | string | no | The reason for a refusal; visible to the agent and in the log | "The lead is already being handled by a manager" |
With accept, you report that the action was applied (or will be applied) on your side. The platform reflects the result in the lead's projection.
200 OK response: the request with its new state.
{
"action_id": "a3d2f1c0-8b7e-4c55-9d2a-6e1f0b9c7d34",
"lead_id": "5d1b2c7e-3f0a-4a8e-9a3b-1c2d3e4f5a6b",
"state": "acked"
}
| Situation | Response |
|---|---|
The request is already closed (acked, rejected, expired) | 409 invalid_state |
No decision, or the value is not accept/reject | 422 validation_failed (errors[].field = /decision) |
| Someone else's lead or request | 404 not_found |
A repeat with the same Idempotency-Key and body | The same response |
The same Idempotency-Key, a different body | 409 idempotency_conflict |
Other errors: the error code catalog.
#Examples
curl -X POST \
https://formula-cream.pro/api/public/v1/leads/$LEAD_ID/actions/$ACTION_ID/ack \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 4f6c1b9a-0d7e-4a23-8e5f-2b3c9d1a7e60" \
-H "Content-Type: application/json" \
-d '{"decision":"accept"}'
# API_KEY=sap_ru_test_EXAMPLE0KEYID_… (an example, not a real key)
await fetch(
`https://formula-cream.pro/api/public/v1/leads/${leadId}/actions/${actionId}/ack`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ decision: "reject", reason: "Лид уже в работе" }),
},
);
httpx.post(
f"https://formula-cream.pro/api/public/v1/leads/{lead_id}/actions/{action_id}/ack",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": str(uuid.uuid4())},
json={"decision": "accept"},
)
$ch = curl_init("https://formula-cream.pro/api/public/v1/leads/$leadId/actions/$actionId/ack");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("API_KEY"),
"Idempotency-Key: " . bin2hex(random_bytes(16)),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["decision" => "accept"]),
]);
#An example handler in an external CRM
The handler verifies the signature, takes the event, reads the lead, applies the action on its side, and replies to the platform. For signature verification and replay protection, see the webhooks section.
// An Express-style handler, a sketch. The signature is verified before the body is parsed.
app.post("/webhooks/platform", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyWebhookSignature(req.headers["webhook-signature"], req.body, WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge receipt quickly, do the work afterwards
const event = JSON.parse(req.body);
if (event.type !== "lead.action_requested") return;
if (await alreadyProcessed(event.id)) return; // delivery is retried
const { lead_id, action_id } = event.data;
const lead = await api(`/leads/${lead_id}`); // a GET with the key
const decision = await applyInCrm(lead, action_id); // your logic
await api(`/leads/${lead_id}/actions/${action_id}/ack`, "POST", {
decision: decision.ok ? "accept" : "reject",
reason: decision.reason,
});
});
verifyWebhookSignature, alreadyProcessed, applyInCrm, and api are functions of your system; here they show the order of steps.
#Sending a nudge
If a "send a nudge" action is requested, the mode and the tenant's settings decide:
- the tenant has auto-sending off by default;
- for a nudge to go out, it must be enabled in the tenant's settings, with the tenant's own channels and own message template in the required language;
- if there is no template in the customer's language or the channel is not enabled, no message is sent; instead a task is created for a manager.
The reason is that the text of our school is not suitable for a tenant's customer in any language.
#Open questions
- The time until a request reaches the
expiredstate when there is no reply is not named (technical). Theexpiredstate of the strategist chat's action requests is a separate entity with its own deadline (Strategist proposals). - The action parameters and the format of
evidenceare not defined in the design; the tables given are preliminary (technical). - There is no separate endpoint for reading a request (
GET .../actions) in the design; retrieval through the lead or through an event withinclude_pii: trueis assumed (technical). - What the agent does on a refusal: repeat the request, wait, or leave the lead without an action (for the owner).
- Whether the "Hand off to an operator" type remains among action requests alongside the
conversation.handoff_requestedevent (technical). - A single actions service: today agents' writes are scattered across about 15 code files; the public request mechanism will appear only after it is extracted. Tasks today are not linked to a lead directly (there is no
lead_id), which is a release prerequisite.