> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Cycle Velocity → Launch Planning

> Pull Linear cycle history and velocity data, then use Mave Agent to build a marketing launch timeline aligned with engineering capacity

## Scenario

Marketing needs to know when features will actually ship — not the optimistic roadmap date, but a timeline grounded in engineering velocity. This job pulls Linear cycle history (completed story points per cycle), calculates velocity trends and remaining capacity, then sends the analysis to Mave Agent for a marketing launch timeline aligned with engineering reality.

**Flow:** Linear GraphQL (cycles with `completedIssueCountHistory`) → calculate velocity → Mavera `POST /api/v1/mave/chat` → Marketing timeline

## Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, requests, time

  LN, MV = os.environ["LINEAR_API_KEY"], os.environ["MAVERA_API_KEY"]
  LB, MB = "https://api.linear.app/graphql", "https://app.mavera.io/api/v1"
  LH = {"Authorization": LN, "Content-Type": "application/json"}
  MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  TEAM_KEY = "ENG"

  cycle_q = """query($tk: String!) {
    cycles(filter: { team: { key: { eq: $tk } } }, first: 12, orderBy: startsAt) {
      nodes { number name startsAt endsAt progress completedScopeHistory scopeHistory }
    }
  }"""

  r = requests.post(LB, headers=LH, json={"query": cycle_q, "variables": {"tk": TEAM_KEY}})
  if r.status_code == 429:
      time.sleep(int(r.headers.get("Retry-After", 60)))
      r = requests.post(LB, headers=LH, json={"query": cycle_q, "variables": {"tk": TEAM_KEY}})
  r.raise_for_status()
  cycles = r.json()["data"]["cycles"]["nodes"]

  time.sleep(0.3)
  pipe_q = """query($tk: String!) {
    issues(filter: { team: { key: { eq: $tk } },
      state: { type: { in: ["started", "unstarted"] } } }, first: 100) {
      nodes { identifier title estimate project { name } }
    }
  }"""
  pipe_issues = requests.post(LB, headers=LH,
      json={"query": pipe_q, "variables": {"tk": TEAM_KEY}}).json()["data"]["issues"]["nodes"]

  vel = [{"cycle": c.get("number", "?"),
          "dates": f"{(c.get('startsAt') or '')[:10]} → {(c.get('endsAt') or '')[:10]}",
          "pts": (c.get("completedScopeHistory") or [0])[-1],
          "scope": (c.get("scopeHistory") or [0])[-1],
          "pct": round((c.get("progress") or 0) * 100, 1)} for c in cycles]

  recent = vel[-6:] if len(vel) >= 6 else vel
  avg = sum(v["pts"] for v in recent) / max(len(recent), 1)
  trend = "increasing" if len(recent) >= 3 and recent[-1]["pts"] > recent[0]["pts"] else "decreasing"

  by_proj, total = {}, 0
  for i in pipe_issues:
      p = (i.get("project") or {}).get("name", "Unassigned")
      pts = i.get("estimate") or 0
      by_proj.setdefault(p, {"n": 0, "pts": 0})
      by_proj[p]["n"] += 1; by_proj[p]["pts"] += pts; total += pts

  vel_txt = "\n".join(f"  Cycle {v['cycle']} ({v['dates']}): {v['pts']}/{v['scope']} pts ({v['pct']}%)" for v in vel)
  pipe_txt = "\n".join(f"  {p}: {d['n']} issues, {d['pts']} pts" for p, d in sorted(by_proj.items(), key=lambda x: -x[1]["pts"]))
  print(f"Avg velocity: {avg:.1f} pts/cycle ({trend}), Pipeline: {total} pts")

  time.sleep(0.3)
  plan = requests.post(f"{MB}/mave/chat", headers=MH, json={
      "message": f"Marketing launch planner.\n\nVELOCITY ({len(vel)} cycles):\n{vel_txt}\n\n"
          f"AVG: {avg:.1f} pts/cycle ({trend})\n\nPIPELINE:\n{pipe_txt}\nTotal: {total} pts\n\n"
          "Produce: 1) Delivery forecast per project 2) Confidence levels 3) Marketing milestone "
          "timeline (prep, teaser, launch, follow-up) 4) Risk factors 5) Big bang vs stagger "
          "recommendation 6) Content production schedule. Use measured velocity, not optimism.",
  }).json()

  print(f"\n{'='*60}\nMARKETING LAUNCH TIMELINE\n{'='*60}")
  print(plan.get("content", "")[:3000])
  ```

  ```javascript JavaScript theme={"dark"}
  const LN = process.env.LINEAR_API_KEY, MV = process.env.MAVERA_API_KEY;
  const LB = "https://api.linear.app/graphql", MB = "https://app.mavera.io/api/v1";
  const LH = { Authorization: LN, "Content-Type": "application/json" };
  const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const TEAM_KEY = "ENG";

  const cycleQ = `query($tk: String!) {
    cycles(filter: { team: { key: { eq: $tk } } }, first: 12, orderBy: startsAt) {
      nodes { number name startsAt endsAt progress completedScopeHistory scopeHistory }
    }
  }`;

  let res = await fetch(LB, { method: "POST", headers: LH,
    body: JSON.stringify({ query: cycleQ, variables: { tk: TEAM_KEY } }) });
  if (res.status === 429) {
    await new Promise(r => setTimeout(r, parseInt(res.headers.get("Retry-After") || "60") * 1000));
    res = await fetch(LB, { method: "POST", headers: LH,
      body: JSON.stringify({ query: cycleQ, variables: { tk: TEAM_KEY } }) });
  }
  const cycles = (await res.json()).data?.cycles?.nodes || [];

  await new Promise(r => setTimeout(r, 300));
  const pipeQ = `query($tk: String!) {
    issues(filter: { team: { key: { eq: $tk } },
      state: { type: { in: ["started","unstarted"] } } }, first: 100) {
      nodes { identifier title estimate project { name } }
    }
  }`;
  const pipeIssues = (await (await fetch(LB, { method: "POST", headers: LH,
    body: JSON.stringify({ query: pipeQ, variables: { tk: TEAM_KEY } }) })).json()).data?.issues?.nodes || [];

  const vel = cycles.map(c => ({
    cycle: c.number || c.name, dates: `${(c.startsAt||"").slice(0,10)} → ${(c.endsAt||"").slice(0,10)}`,
    pts: (c.completedScopeHistory||[0]).slice(-1)[0], scope: (c.scopeHistory||[0]).slice(-1)[0],
    pct: Math.round((c.progress||0)*1000)/10 }));

  const recent = vel.slice(-6);
  const avg = recent.reduce((s,v) => s+v.pts, 0) / Math.max(recent.length, 1);
  const trend = recent.length >= 3 && recent.at(-1).pts > recent[0].pts ? "increasing" : "decreasing";

  const byProj = {}; let total = 0;
  for (const i of pipeIssues) {
    const p = i.project?.name || "Unassigned";
    byProj[p] = byProj[p] || { n: 0, pts: 0 };
    byProj[p].n++; byProj[p].pts += i.estimate || 0; total += i.estimate || 0;
  }

  const velTxt = vel.map(v => `  Cycle ${v.cycle} (${v.dates}): ${v.pts}/${v.scope} (${v.pct}%)`).join("\n");
  const pipeTxt = Object.entries(byProj).sort(([,a],[,b])=>b.pts-a.pts)
    .map(([p,d])=>`  ${p}: ${d.n} issues, ${d.pts} pts`).join("\n");

  await new Promise(r => setTimeout(r, 300));
  const plan = await fetch(`${MB}/mave/chat`, { method: "POST", headers: MH,
    body: JSON.stringify({
      message: `Launch planner.\n\nVELOCITY (${vel.length} cycles):\n${velTxt}\n\nAVG: ${avg.toFixed(1)} (${trend})\n\nPIPELINE:\n${pipeTxt}\nTotal: ${total} pts\n\n1) Forecast 2) Confidence 3) Timeline 4) Risks 5) Strategy 6) Content schedule. Measured velocity only.`,
    }),
  }).then(r => r.json());

  console.log(`\n${"=".repeat(60)}\nMARKETING LAUNCH TIMELINE`);
  console.log((plan.content || "").slice(0, 3000));
  ```
</CodeGroup>

## Example Output

```text theme={"dark"}
Avg velocity: 34.2 pts/cycle (increasing), Pipeline: 142 pts

MARKETING LAUNCH TIMELINE
============================================================

## Delivery Forecast
| Project | Remaining | Est. Cycles | Ship Date | Confidence |
|---------|-----------|-------------|-----------|------------|
| API v3 | 48 pts | 1.4 cycles | Mar 28 | High |
| Dashboard Redesign | 62 pts | 1.8 cycles | Apr 11 | Medium |
| Mobile App | 32 pts | 0.9 cycles | Mar 21 | High |

## Marketing Milestones — API v3 (ship ~Mar 28)
- Mar 14: Begin blog draft + API migration guide
- Mar 21: Teaser on Twitter
- Mar 28: Launch — blog, changelog, developer email
- Apr 4: Follow-up case study with beta tester

## Recommended Strategy
STAGGER. Ship Mobile App first (smallest), then API v3 one week
later, then Dashboard. 1 week between launches for content prep.

## Content Schedule
| Content | API v3 | Dashboard | Mobile |
|---------|--------|-----------|--------|
| Blog draft | Mar 14 | Mar 28 | Mar 7 |
| Email copy | Mar 21 | Apr 4 | Mar 14 |
| Social | Mar 25 | Apr 8 | Mar 18 |
```

## Error Handling

<AccordionGroup>
  <Accordion title="Missing scope history">Cycles without estimates return empty `completedScopeHistory`. Enable estimates in Linear (Settings → Teams → Estimation) for accurate velocity tracking.</Accordion>
  <Accordion title="Cycle naming">Linear auto-names cycles by date range. The code uses `number` as identifier. Adjust if your team uses custom names.</Accordion>
  <Accordion title="Velocity outliers">Holidays or crunch sprints skew averages. The code uses the last 6 cycles — consider trimming the highest and lowest values for robustness.</Accordion>
</AccordionGroup>
