Turn Appointment Setter hand-offs into Linear or Asana tasks
A handler for the webhook receiver that creates a Linear issue or an Asana task when the Appointment Setter hands a conversation to a person, with the brief.
Last updated
- Linear
- Asana
When the Appointment Setter can't take a conversation further (the prospect asks for a person, raises pricing or a contract, or names someone else) it escalates: it stops replying and emails the campaign's notification address. For a team that runs its day from Linear or Asana, this recipe puts the hand-off where the work is. It's a handler for Receive replies once and fan them out: on a hand-off, it creates one task with the Setter's brief, the escalation reason, who the prospect is and what they said, and a link to the app's Inbox.
Before you start
- The receiver from Receive replies once and fan them out. This handler goes in its
handlersdirectory and is switched on withHANDLERS=tasks. - For Linear: a personal API key in
LINEAR_API_KEYand the team's id inLINEAR_TEAM_ID(from the team's settings, or theteamsquery in Linear's API explorer). Optionally a label id inLINEAR_LABEL_ID. - For Asana: a personal access token in
ASANA_TOKENand the project's gid inASANA_PROJECT_ID(the number in the project's URL). - Set one of the two; with both set, the handler creates both.
What counts as a hand-off
The prospect_response payload's ai_response carries the Setter's decision. A hand-off is agent_action: "escalate", and the conversation then shows conversation_status: "closed_escalated"; escalation_reason says why in a few words and sdr_brief is the Setter's summary for whoever picks it up. The handler acts on those and returns for everything else. A reply that arrives while the Setter is off has no agent_action, so campaigns without the Setter never create tasks; the console or Slack handler covers them.
Delivery is once per lead per campaign, and again when a later reply turns positive. A hand-off is rarely the first reply, so most hand-offs arrive on that second delivery; the handler doesn't depend on which one it is.
The handler
// handlers/tasks.mjs
const APP_INBOX_URL = "https://app.versionseven.ai/inbox";
const linear = process.env.LINEAR_API_KEY && process.env.LINEAR_TEAM_ID;
const asana = process.env.ASANA_TOKEN && process.env.ASANA_PROJECT_ID;
if (!linear && !asana) throw new Error("Set LINEAR_API_KEY + LINEAR_TEAM_ID, or ASANA_TOKEN + ASANA_PROJECT_ID");
function isHandoff(event) {
const ai = event.ai_response ?? {};
return ai.agent_action === "escalate" || ai.conversation_status === "closed_escalated";
}
function describe({ campaign_id, event }) {
const lead = event.lead ?? {};
const ai = event.ai_response ?? {};
const name = [lead.first_name, lead.last_name].filter(Boolean).join(" ") || "A prospect";
const who = lead.company ? `${name}, ${lead.title ? `${lead.title} at ` : ""}${lead.company}` : name;
const title = `Reply from ${name}${lead.company ? ` (${lead.company})` : ""}: ${ai.escalation_reason ?? "needs a person"}`;
const body = [
`**Campaign:** ${event.campaign ?? campaign_id} (${event.channel ?? "unknown channel"})`,
`**Who:** ${who}${lead.email ? ` · ${lead.email}` : ""}${lead.linkedin_profile ? ` · ${lead.linkedin_profile}` : ""}`,
ai.escalation_reason ? `**Why the Setter handed off:** ${ai.escalation_reason}` : null,
ai.sdr_brief ? `**Brief:** ${ai.sdr_brief}` : null,
event.prospect_message ? `**They wrote:**\n> ${String(event.prospect_message).split("\n").join("\n> ")}` : null,
`**Reply from the Inbox:** ${APP_INBOX_URL}`,
`_Delivery ${event.idempotency_key}_`,
].filter(Boolean);
return { title, body: body.join("\n\n") };
}
async function createLinearIssue({ title, body }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: { Authorization: process.env.LINEAR_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }`,
variables: {
input: {
teamId: process.env.LINEAR_TEAM_ID,
title,
description: body,
...(process.env.LINEAR_LABEL_ID ? { labelIds: [process.env.LINEAR_LABEL_ID] } : {}),
},
},
}),
signal: AbortSignal.timeout(10_000),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.errors || !data.data?.issueCreate?.success) {
throw new Error(`Linear answered ${response.status}: ${JSON.stringify(data.errors ?? data)}`);
}
console.log(`Linear issue ${data.data.issueCreate.issue.identifier}: ${data.data.issueCreate.issue.url}`);
}
async function createAsanaTask({ title, body }) {
const response = await fetch("https://app.asana.com/api/1.0/tasks", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ASANA_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { name: title, notes: body.replace(/\*\*/g, ""), projects: [process.env.ASANA_PROJECT_ID] } }),
signal: AbortSignal.timeout(10_000),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`Asana answered ${response.status}: ${JSON.stringify(data.errors ?? data)}`);
console.log(`Asana task: ${data.data?.permalink_url ?? data.data?.gid}`);
}
export default async function handle(delivery) {
if (!isHandoff(delivery.event)) return;
const task = describe(delivery);
if (linear) await createLinearIssue(task);
if (asana) await createAsanaTask(task);
}# handlers/tasks.py
import json
import os
import requests
APP_INBOX_URL = "https://app.versionseven.ai/inbox"
LINEAR = bool(os.environ.get("LINEAR_API_KEY") and os.environ.get("LINEAR_TEAM_ID"))
ASANA = bool(os.environ.get("ASANA_TOKEN") and os.environ.get("ASANA_PROJECT_ID"))
if not LINEAR and not ASANA:
raise SystemExit("Set LINEAR_API_KEY + LINEAR_TEAM_ID, or ASANA_TOKEN + ASANA_PROJECT_ID")
def is_handoff(event: dict) -> bool:
ai = event.get("ai_response") or {}
return ai.get("agent_action") == "escalate" or ai.get("conversation_status") == "closed_escalated"
def describe(delivery: dict) -> dict:
event = delivery["event"]
lead = event.get("lead") or {}
ai = event.get("ai_response") or {}
name = " ".join(part for part in (lead.get("first_name"), lead.get("last_name")) if part) or "A prospect"
role = f"{lead['title']} at " if lead.get("title") else ""
who = f"{name}, {role}{lead['company']}" if lead.get("company") else name
company = f" ({lead['company']})" if lead.get("company") else ""
title = f"Reply from {name}{company}: {ai.get('escalation_reason') or 'needs a person'}"
parts = [
f"**Campaign:** {event.get('campaign') or delivery['campaign_id']} ({event.get('channel') or 'unknown channel'})",
f"**Who:** {who}" + (f" · {lead['email']}" if lead.get("email") else "") + (f" · {lead['linkedin_profile']}" if lead.get("linkedin_profile") else ""),
f"**Why the Setter handed off:** {ai['escalation_reason']}" if ai.get("escalation_reason") else None,
f"**Brief:** {ai['sdr_brief']}" if ai.get("sdr_brief") else None,
"**They wrote:**\n> " + "\n> ".join(str(event["prospect_message"]).split("\n")) if event.get("prospect_message") else None,
f"**Reply from the Inbox:** {APP_INBOX_URL}",
f"_Delivery {event.get('idempotency_key')}_",
]
return {"title": title, "body": "\n\n".join(part for part in parts if part)}
def create_linear_issue(task: dict) -> None:
payload = {
"query": "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }",
"variables": {"input": {"teamId": os.environ["LINEAR_TEAM_ID"], "title": task["title"], "description": task["body"]}},
}
if os.environ.get("LINEAR_LABEL_ID"):
payload["variables"]["input"]["labelIds"] = [os.environ["LINEAR_LABEL_ID"]]
response = requests.post(
"https://api.linear.app/graphql",
headers={"Authorization": os.environ["LINEAR_API_KEY"], "Content-Type": "application/json"},
json=payload,
timeout=10,
)
data = response.json() if response.content else {}
created = (data.get("data") or {}).get("issueCreate") or {}
if not response.ok or data.get("errors") or not created.get("success"):
raise RuntimeError(f"Linear answered {response.status_code}: {json.dumps(data.get('errors') or data)}")
print(f"Linear issue {created['issue']['identifier']}: {created['issue']['url']}", flush=True)
def create_asana_task(task: dict) -> None:
response = requests.post(
"https://app.asana.com/api/1.0/tasks",
headers={"Authorization": f"Bearer {os.environ['ASANA_TOKEN']}"},
json={"data": {"name": task["title"], "notes": task["body"].replace("**", ""), "projects": [os.environ["ASANA_PROJECT_ID"]]}},
timeout=10,
)
data = response.json() if response.content else {}
if not response.ok:
raise RuntimeError(f"Asana answered {response.status_code}: {json.dumps(data.get('errors') or data)}")
print(f"Asana task: {(data.get('data') or {}).get('permalink_url') or (data.get('data') or {}).get('gid')}", flush=True)
def handle(delivery: dict) -> None:
if not is_handoff(delivery["event"]):
return
task = describe(delivery)
if LINEAR:
create_linear_issue(task)
if ASANA:
create_asana_task(task)Start the receiver with the handler on, alongside whatever else runs:
HANDLERS=tasks,slack LINEAR_API_KEY=lin_api_… LINEAR_TEAM_ID=… node receiver.mjs
HANDLERS=tasks ASANA_TOKEN=… ASANA_PROJECT_ID=1200… flask --app receiver run --port 3000Test it
The examples from GET /v1/webhooks/examples include an out-of-office reply with agent_action: "wait", which the handler ignores. To see a task, change its agent_action to escalate and give it a reason before signing it:
BODY=$(curl -s https://api.versionseven.ai/v1/webhooks/examples -H "Authorization: Bearer $VICTORIA_API_KEY" \
| python3 -c 'import json,sys; e=json.load(sys.stdin)["examples"][2]; e["ai_response"].update({"agent_action":"escalate","conversation_status":"closed_escalated","escalation_reason":"asked about contract terms"}); print(json.dumps(e))')
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$VICTORIA_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/hooks/$CAMPAIGN_ID -H "Content-Type: application/json" -H "X-Signature-256: sha256=$SIGNATURE" --data "$BODY"A Linear issue titled Reply from Michael Roberts (RetailCo): asked about contract terms appears, with the brief and the message in its description. Send it again and nothing is created: the receiver drops the repeated idempotency_key before any handler runs. If the task tool was down, POST /replay/<key> on the receiver creates it later.
What to expect
- One task per hand-off delivery. A prospect who is handed off, answered by a person, and later handed off again produces a second delivery only if that later reply is positive after an earlier one wasn't; in practice that's one task per prospect.
- The task links to the Inbox page, not to the conversation: the payload identifies the lead by name and email, not by an id the Inbox can open. The campaign name and the prospect's name find it in a few seconds.
- Unclaimed hand-offs also get the app's own reminder emails at 24 and 72 hours, so a task that sits is noticed twice. AI Appointment Setter covers what triggers an escalation and what the Setter does meanwhile.
- Linear's personal API keys go in the
Authorizationheader as they are, withoutBearer; Asana's tokens takeBearer. Both APIs answer401when a key is wrong and the handler fails loudly, so the receiver's log shows it and replay fixes it.
Next steps
- Receive replies once and fan them out, the receiver this handler runs in.
- Create and advance CRM deals from replies and meetings, the handler that tracks the pipeline side.
- AI Appointment Setter for how escalations work.