Cookbook
View as Markdown

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

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 handlers directory and is switched on with HANDLERS=tasks.
  • For Linear: a personal API key in LINEAR_API_KEY and the team's id in LINEAR_TEAM_ID (from the team's settings, or the teams query in Linear's API explorer). Optionally a label id in LINEAR_LABEL_ID.
  • For Asana: a personal access token in ASANA_TOKEN and the project's gid in ASANA_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);
}

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 3000

Test 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 Authorization header as they are, without Bearer; Asana's tokens take Bearer. Both APIs answer 401 when a key is wrong and the handler fails loudly, so the receiver's log shows it and replay fixes it.

Next steps