# 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.

Works with: Linear, Asana.

Endpoints used:

- [`POST /v1/campaigns/{campaign_id}/webhooks`](https://docs.versionseven.ai/api-reference/campaigns/create-webhook) Create a webhook
- [`GET /v1/webhooks/examples`](https://docs.versionseven.ai/api-reference/reference/list-webhook-examples) List webhook examples

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](https://docs.versionseven.ai/cookbook/webhook-receiver): 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](https://docs.versionseven.ai/cookbook/webhook-receiver). 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`](https://docs.versionseven.ai/api-reference/webhooks/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

**Node.js**

```javascript
// 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);
}
```

**Python**

```python
# 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:

```bash
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`](https://docs.versionseven.ai/api-reference/reference/list-webhook-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:

```bash
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](https://docs.versionseven.ai/guides/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

- [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver), the receiver this handler runs in.
- [Create and advance CRM deals from replies and meetings](https://docs.versionseven.ai/cookbook/replies-and-meetings-to-deals), the handler that tracks the pipeline side.
- [AI Appointment Setter](https://docs.versionseven.ai/guides/ai-appointment-setter) for how escalations work.
