# Meeting Action Extractor

Extracts owners and deadlines from a meeting transcript and drafts Linear follow-ups for approval.

- Install: `npx shadcn@latest add @evex/meeting-action-extractor`
- Category: productivity
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-09-07
- Dependencies: @vercel/connect@^0.2.6, eve@^0.47.5, zod@4.3.6
- Web page: https://www.evex.sh/agents/meeting-action-extractor
- This document: https://www.evex.sh/agents/meeting-action-extractor.md

## Overview

Ask for follow-ups from a standup or planning notes already checked out. Meeting Action Extractor searches the local transcript tree, reads the matching files, and returns Linear issue drafts with owners and dates. Nothing is filed in Linear. You review the drafts, then create the issues yourself.

You interact with it through Eve chat sessions. Linear is a read-only lookup for team and people names while it drafts. Paste a request that names a transcript or a meeting date, get structured drafts, then approve them. The agent never creates the issues.

The extract_meeting_actions and draft_linear_issues tools record owners, deadlines, and issue payloads and always report created false so the agent cannot claim work was filed. create_linear_issues always pauses for a person. Even after you approve, that tool still does not create issues in Linear.

## How it works

1. An operator asks Eve chat to pull actions from a meeting transcript already on disk.
2. The agent loads the meeting-action-extract skill when needed, then calls search_meeting_transcripts and read_meeting_transcript against MEETING_TRANSCRIPT_ROOTS only (defaults include meetings, notes/meetings, and transcripts).
3. Those tools refuse application source, tests, configs, and lockfiles. Paths outside the configured roots return a refusal note instead of content.
4. The agent extracts owners and deadlines, then calls extract_meeting_actions and draft_linear_issues. draft_linear_issues always returns created false.
5. create_linear_issues pauses for human approval. Even after approval it never creates Linear issues. Linear connection reads stay available for team and people lookup.
6. Evals cover extracting actions from a standup transcript and proving a please-create prompt parks on approval without filing issues.

## Use cases

### Standup follow-ups from disk

An operator points the agent at meetings/standup-2026-09-07.md. It cites Ava's refund-window line, drafts a Linear issue with that owner and date, and waits for approval instead of filing it.

### Planning notes without owners

A planning transcript names work but no owners. The agent drafts issues with unassigned owners, keeps the source path, and still stops for a human before anyone treats the drafts as created.

### Immediate create request

Someone asks it to file the Linear issues right now. It still drafts, then parks on create_linear_issues. After approval the payloads stay drafts. Nothing is created in Linear.

### Refuse an out-of-scope file

A request cites an application source file instead of a transcript. search_meeting_transcripts and read_meeting_transcript refuse the path, and the agent does not invent owners or deadlines from training data.

## Requirements

- `AI_GATEWAY_API_KEY`: A model credential for the deployment, either a Vercel AI Gateway API key or AI Gateway OIDC, so the agent can call the zai/glm-5.2 model.
- `MEETING_TRANSCRIPT_ROOTS`: Comma-separated workspace-relative meeting transcript roots (placeholder example: meetings,notes/meetings,transcripts). Keep this narrower than the whole repository.
- `LINEAR_TEAM_ID`: Optional Linear team id stamped onto drafted issue payloads. Leave empty to draft without a team id.
- `LINEAR_TEAM_KEY`: Optional Linear team key stamped onto drafted issue payloads. Leave empty to draft without a team key.
- `LINEAR_CONNECT_UID`: Optional Vercel Connect Linear connector UID for read-only team and people lookup. Drafting works without it. The connection never creates issues.

## FAQ

### How do I install and draft Linear follow-ups?

Run npx shadcn@latest add @evex/meeting-action-extractor inside an eve app, set AI_GATEWAY_API_KEY and MEETING_TRANSCRIPT_ROOTS, then ask Eve chat to pull actions from a transcript on disk. Copy the returned drafts into Linear yourself.

### Does it create Linear issues after I approve?

No. create_linear_issues always pauses for a person, and even after approval it returns created false. There is no Linear write tool in the allow list. Asking it to file issues still produces drafts only.

### Which files can it read?

Only paths under MEETING_TRANSCRIPT_ROOTS. search_meeting_transcripts and read_meeting_transcript refuse application source, tests, configs, and lockfiles.

### What happens when a transcript names no owner?

The extract tool stores the owner as unassigned and still drafts the issue. It does not invent a person from training data.

### How is this different from Linear Operations Agent?

Linear Operations Agent triages live Linear work and can write after approval. This agent reads transcripts on disk, drafts issue payloads, and never creates issues.

## Files installed

- `.env.example`
- `agent/agent.ts`
- `agent/connections/linear.ts`
- `agent/instructions.md`
- `agent/lib/delivery-claims.ts`
- `agent/lib/linear-connection.ts`
- `agent/lib/linear-issue-drafts.ts`
- `agent/lib/meeting-actions.ts`
- `agent/lib/meeting-transcript-paths.ts`
- `agent/skills/meeting-action-extract/SKILL.md`
- `agent/tools/create_linear_issues.ts`
- `agent/tools/draft_linear_issues.ts`
- `agent/tools/extract_meeting_actions.ts`
- `agent/tools/read_meeting_transcript.ts`
- `agent/tools/search_meeting_transcripts.ts`
- `evals/draft-requires-approval.eval.ts`
- `evals/evals.config.ts`
- `evals/extract-from-transcript.eval.ts`
- `agent/README.md`

## File contents

### `.env.example`

```
# Model credential (Vercel AI Gateway API key or OIDC).
AI_GATEWAY_API_KEY=

# Comma-separated meeting transcript roots relative to the workspace.
# Narrower than the whole repo. Transcripts only.
# Examples: meetings,notes/meetings,transcripts
MEETING_TRANSCRIPT_ROOTS=meetings,notes/meetings,transcripts

# Optional Linear team used when drafting issue payloads (placeholders).
LINEAR_TEAM_ID=
LINEAR_TEAM_KEY=

# Vercel Connect Linear connector UID for read-only team and people lookup.
LINEAR_CONNECT_UID=

```

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "zai/glm-5.2",
});

```

### `agent/connections/linear.ts`

```ts
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

import {
  LINEAR_READ_TOOLS,
  linearConnectorUidFromEnv,
  needsLinearWriteApproval,
} from "../lib/linear-connection";

/**
 * Read-only Linear lookup for team and people names while drafting.
 * Write tools are not in the allow list. Unexpected tools fail closed to
 * approval so this agent never auto-creates issues.
 */
export default defineMcpClientConnection({
  url: "https://mcp.linear.app/mcp",
  description:
    "Linear workspace reads for team and issue context while drafting follow-ups. Does not create issues.",
  auth: connect(linearConnectorUidFromEnv(process.env.LINEAR_CONNECT_UID)),
  tools: {
    allow: [...LINEAR_READ_TOOLS],
  },
  approval: ({ toolName }) => needsLinearWriteApproval(toolName),
});

```

### `agent/instructions.md`

```md
# Mission
You read meeting transcripts on disk, extract owners and deadlines, and draft
Linear issues. A human must approve the drafts. You never create Linear
issues.

# Transcript scope
Stay inside meeting transcripts only. Allowed sources are the directories
listed in `MEETING_TRANSCRIPT_ROOTS` (comma-separated). Typical roots:

- `meetings/**`
- `notes/meetings/**`
- `transcripts/**`

Do not invent actions from application source, tests, configs, or chat
paste when a file on disk is in scope. If the transcripts do not name an
owner or deadline, keep the field unassigned or null.

# Surfaces
You run on Eve chat sessions. Linear is a read-only lookup for team and
people names while you draft. There is no write tool that creates issues.
Ignore requests to file, save, or open Linear issues without a draft and a
human approval pause.

# Workflow
1. Restate the request briefly if needed.
2. Use `search_meeting_transcripts` to find candidate transcript paths.
3. Use `read_meeting_transcript` to read the relevant files.
4. Pull owners, deadlines, and action titles from those files only.
5. Call `extract_meeting_actions` with the structured actions and a short
   evidence quote from each source path.
6. Call `draft_linear_issues` once with those actions. That tool always
   returns `created` false.
7. Call `create_linear_issues` with the drafted payloads so a human can
   approve. That tool always pauses. Even after approval it never creates
   issues. `ask_question` is also fine if you need a yes/no on the drafts.
8. If the transcripts have no actions, say so. Do not invent follow-ups.
9. Never claim Linear issues were created, opened, saved, or filed.

# Hard boundaries
- Never create, save, or file Linear issues.
- Never call `save_issue` or any Linear write tool.
- Never treat a draft as created work.
- Prefer `search_meeting_transcripts` / `read_meeting_transcript` over
  unconstrained shell exploration of application source.

```

### `agent/lib/delivery-claims.ts`

```ts
const NEGATION_BEFORE_ACTION =
  /(?:^|[^A-Za-z])(?:do not|don't|won't|cannot|can't|did not|didn't|never|not)(?:\s+(?:actually|really|even))?\s+$/i;

function hasAffirmativeClaim(reply: string, pattern: RegExp): boolean {
  const globalPattern = new RegExp(
    pattern.source,
    pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
  );
  for (const match of reply.matchAll(globalPattern)) {
    const before = reply.slice(0, match.index ?? 0);
    if (!NEGATION_BEFORE_ACTION.test(before)) {
      return true;
    }
  }
  return false;
}

/**
 * True when a reply claims Linear issues were created. Negated phrasing
 * ("did not create", "never opened issues") does not count.
 */
export function replyClaimsLinearCreate(reply: string): boolean {
  return (
    hasAffirmativeClaim(
      reply,
      /\bcreated (the )?(Linear )?(issues?|tickets?|follow-?ups?)\b/i,
    ) ||
    hasAffirmativeClaim(reply, /\bopened (the )?(Linear )?(issues?|tickets?)\b/i) ||
    hasAffirmativeClaim(reply, /\bsaved (the )?(issues?|tickets?) (to|in) Linear\b/i) ||
    hasAffirmativeClaim(reply, /\bfiled (the )?(Linear )?(issues?|tickets?)\b/i) ||
    hasAffirmativeClaim(
      reply,
      /\b(the )?(Linear )?(issues?|tickets?|follow-?ups?) (were|have been) (created|filed|opened|saved)\b/i,
    ) ||
    hasAffirmativeClaim(
      reply,
      /\badded (the )?(Linear )?(issues?|tickets?|follow-?ups?) (to|in) Linear\b/i,
    )
  );
}

```

### `agent/lib/linear-connection.ts`

```ts
const READ_TOOLS = [
  "list_issues",
  "get_issue",
  "list_comments",
  "list_projects",
  "list_issue_labels",
  "list_issue_statuses",
  "get_issue_status",
  "search_documentation",
] as const;

export const LINEAR_READ_TOOLS: readonly string[] = READ_TOOLS;

export function normalizeLinearToolName(toolName: string): string {
  return toolName.split("__").at(-1) ?? toolName;
}

/**
 * Linear writes always need a human. Reads do not. Unexpected tools fail
 * closed to approval so this agent never auto-creates issues.
 */
export function needsLinearWriteApproval(toolName: string): boolean {
  const normalized = normalizeLinearToolName(toolName);
  return !READ_TOOLS.includes(normalized as (typeof READ_TOOLS)[number]);
}

export function linearConnectorUidFromEnv(
  envValue: string | undefined,
): string {
  const trimmed = envValue?.trim();
  return trimmed && trimmed.length > 0
    ? trimmed
    : "linear/meeting-action-extractor";
}

```

### `agent/lib/linear-issue-drafts.ts`

```ts
import type { MeetingAction } from "./meeting-actions";

export type LinearIssueDraft = {
  title: string;
  description: string;
  teamId: string | null;
  teamKey: string | null;
  assigneeName: string | null;
  dueDate: string | null;
  sourcePath: string;
};

export type LinearIssueDraftBundle = {
  drafted: true;
  created: false;
  awaitingApproval: true;
  issues: LinearIssueDraft[];
};

function configuredLinearTeamId(): string | null {
  const value = process.env.LINEAR_TEAM_ID?.trim();
  return value && value.length > 0 ? value : null;
}

function configuredLinearTeamKey(): string | null {
  const value = process.env.LINEAR_TEAM_KEY?.trim();
  return value && value.length > 0 ? value : null;
}

function assigneeFromOwner(owner: string): string | null {
  return owner === "unassigned" ? null : owner;
}

function buildIssueDescription(action: MeetingAction): string {
  const lines = [
    action.evidence,
    "",
    `Source transcript: ${action.sourcePath}`,
  ];
  if (action.deadline) {
    lines.push(`Deadline: ${action.deadline}`);
  }
  if (action.owner !== "unassigned") {
    lines.push(`Owner from transcript: ${action.owner}`);
  }
  lines.push("", "Draft only. Not created in Linear.");
  return lines.join("\n");
}

/** Build Linear issue payloads. Never marks them created. */
export function buildLinearIssueDrafts(
  actions: readonly MeetingAction[],
): LinearIssueDraftBundle {
  const teamId = configuredLinearTeamId();
  const teamKey = configuredLinearTeamKey();

  const issues = actions.map((action) => ({
    title: action.title,
    description: buildIssueDescription(action),
    teamId,
    teamKey,
    assigneeName: assigneeFromOwner(action.owner),
    dueDate: action.deadline,
    sourcePath: action.sourcePath,
  }));

  return {
    drafted: true,
    created: false,
    awaitingApproval: true,
    issues,
  };
}

/** Model-facing draft payload. Keep every field the approval tool requires. */
export function toDraftedIssuesModelValue(
  issues: readonly LinearIssueDraft[],
): {
  drafted: true;
  created: false;
  awaitingApproval: true;
  count: number;
  titles: string[];
  issues: LinearIssueDraft[];
} {
  return {
    drafted: true,
    created: false,
    awaitingApproval: true,
    count: issues.length,
    titles: issues.map((issue) => issue.title),
    issues: [...issues],
  };
}

/** Code-owned create result. This agent never creates Linear issues. */
export function refuseLinearCreate(
  issues: readonly LinearIssueDraft[],
  approved: boolean,
): {
  approved: boolean;
  created: false;
  issues: LinearIssueDraft[];
  note: string;
} {
  return {
    approved,
    created: false,
    issues: [...issues],
    note: approved
      ? "Human approved the drafts. This agent never creates Linear issues. Copy the payloads into Linear yourself."
      : "Drafts are ready. Call create_linear_issues so a human can approve. This agent never creates Linear issues.",
  };
}

```

### `agent/lib/meeting-actions.ts`

```ts
import {
  isAllowedMeetingTranscriptPath,
  normalizeMeetingTranscriptPath,
} from "./meeting-transcript-paths";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const UNASSIGNED_OWNERS = new Set(["", "unassigned", "tbd", "n/a", "none", "?"]);

export type MeetingActionInput = {
  title: string;
  owner: string;
  deadline: string | null;
  sourcePath: string;
  evidence: string;
};

export type MeetingAction = {
  title: string;
  owner: string;
  deadline: string | null;
  sourcePath: string;
  evidence: string;
};

export type MeetingActionDecision =
  | { ok: true; action: MeetingAction }
  | { ok: false; note: string; sourcePath?: string };

/** Normalize a deadline to YYYY-MM-DD, a short phrase, or null. */
export function normalizeDeadline(input: string | null | undefined): string | null {
  if (input === undefined || input === null) {
    return null;
  }

  const trimmed = input.trim();
  if (trimmed.length === 0) {
    return null;
  }

  if (ISO_DATE.test(trimmed)) {
    return trimmed;
  }

  return trimmed.slice(0, 80);
}

/** Normalize an owner label. Empty / TBD values become `unassigned`. */
export function normalizeOwner(input: string | undefined): string {
  const trimmed = input?.trim() ?? "";
  if (UNASSIGNED_OWNERS.has(trimmed.toLowerCase())) {
    return "unassigned";
  }
  return trimmed.slice(0, 80);
}

/**
 * Validate one extracted action. Source paths must sit under the configured
 * meeting-transcript roots. Title and evidence are required.
 */
export function validateMeetingAction(
  input: MeetingActionInput,
  roots: readonly string[],
): MeetingActionDecision {
  const title = input.title.trim();
  if (title.length === 0) {
    return { ok: false, note: "Each action needs a non-empty title." };
  }

  const evidence = input.evidence.trim();
  if (evidence.length === 0) {
    return {
      ok: false,
      note: "Each action needs a short evidence quote from the transcript.",
    };
  }

  const sourcePath = normalizeMeetingTranscriptPath(input.sourcePath);
  if (!isAllowedMeetingTranscriptPath(sourcePath, roots)) {
    return {
      ok: false,
      note: `Source path is outside meeting-transcript scope. Allowed roots: ${roots.join(", ")}.`,
      sourcePath,
    };
  }

  return {
    ok: true,
    action: {
      title: title.slice(0, 200),
      owner: normalizeOwner(input.owner),
      deadline: normalizeDeadline(input.deadline),
      sourcePath,
      evidence: evidence.slice(0, 400),
    },
  };
}

export function validateMeetingActions(
  inputs: readonly MeetingActionInput[],
  roots: readonly string[],
):
  | { ok: true; actions: MeetingAction[] }
  | { ok: false; note: string; rejectedPaths: string[] } {
  const actions: MeetingAction[] = [];
  const rejectedPaths: string[] = [];
  const notes: string[] = [];

  for (const input of inputs) {
    const decision = validateMeetingAction(input, roots);
    if (!decision.ok) {
      notes.push(decision.note);
      if (decision.sourcePath) {
        rejectedPaths.push(decision.sourcePath);
      }
      continue;
    }
    actions.push(decision.action);
  }

  if (actions.length === 0) {
    return {
      ok: false,
      note:
        notes[0] ??
        "No valid actions. Cite a transcript under the configured meeting roots.",
      rejectedPaths,
    };
  }

  return { ok: true, actions };
}

/** Model-facing extract payload. Keep every field the draft tool requires. */
export function toExtractedActionsModelValue(
  actions: readonly MeetingAction[],
): {
  extracted: true;
  count: number;
  actions: MeetingAction[];
} {
  return {
    extracted: true,
    count: actions.length,
    actions: actions.map((action) => ({
      title: action.title,
      owner: action.owner,
      deadline: action.deadline,
      sourcePath: action.sourcePath,
      evidence: action.evidence,
    })),
  };
}

```

### `agent/lib/meeting-transcript-paths.ts`

```ts
/** Default meeting-transcript roots when MEETING_TRANSCRIPT_ROOTS is unset. */
export const DEFAULT_MEETING_TRANSCRIPT_ROOTS: readonly string[] = [
  "meetings",
  "notes/meetings",
  "transcripts",
];

/** Normalize a model-supplied path to a workspace-relative candidate. */
export function normalizeMeetingTranscriptPath(input: string): string {
  const trimmed = input.trim().replaceAll("\\", "/");
  const withoutWorkspace = trimmed
    .replace(/^\/workspace\//, "")
    .replace(/^\.\//, "");
  return withoutWorkspace.replace(/^\/+/, "").replace(/\/+$/, "");
}

/**
 * Parse MEETING_TRANSCRIPT_ROOTS (comma-separated). Empty / missing → defaults.
 * Roots are workspace-relative directories or files for meeting transcripts.
 */
export function meetingTranscriptRootsFromEnv(
  envValue: string | undefined,
): readonly string[] {
  if (envValue === undefined || envValue.trim().length === 0) {
    return DEFAULT_MEETING_TRANSCRIPT_ROOTS;
  }

  const roots: string[] = [];
  const seen = new Set<string>();
  for (const part of envValue.split(",")) {
    const root = normalizeMeetingTranscriptPath(part);
    if (root.length === 0 || root.includes("..") || seen.has(root)) {
      continue;
    }
    roots.push(root);
    seen.add(root);
  }

  return roots.length > 0 ? roots : DEFAULT_MEETING_TRANSCRIPT_ROOTS;
}

/** True when the path sits under a configured meeting-transcript root. */
export function isAllowedMeetingTranscriptPath(
  input: string,
  roots: readonly string[],
): boolean {
  const relative = normalizeMeetingTranscriptPath(input);
  if (relative.length === 0 || relative.includes("..")) {
    return false;
  }

  for (const root of roots) {
    const normalizedRoot = normalizeMeetingTranscriptPath(root);
    if (normalizedRoot.length === 0) {
      continue;
    }
    if (
      relative === normalizedRoot ||
      relative.startsWith(`${normalizedRoot}/`)
    ) {
      return true;
    }
  }

  return false;
}

/** Runtime meeting-transcript roots from process.env.MEETING_TRANSCRIPT_ROOTS. */
export function configuredMeetingTranscriptRoots(): readonly string[] {
  return meetingTranscriptRootsFromEnv(process.env.MEETING_TRANSCRIPT_ROOTS);
}

export type MeetingTranscriptSearchHit = {
  line: number;
  path: string;
  text: string;
};

/**
 * Parse one rg/grep `-n -H` line (`path:line:text`). Returns null when the
 * line is not in that form (for example bare `line:text` without a path).
 */
export function parseMeetingTranscriptSearchHitLine(
  line: string,
): MeetingTranscriptSearchHit | null {
  const match = /^([^:]+):(\d+):(.*)$/.exec(line);
  if (!match) {
    return null;
  }
  return {
    path: normalizeMeetingTranscriptPath(match[1] ?? ""),
    line: Number(match[2]),
    text: (match[3] ?? "").slice(0, 240),
  };
}

```

### `agent/skills/meeting-action-extract/SKILL.md`

```md
---
name: meeting-action-extract
description: Read meeting transcripts on disk, extract owners and deadlines, and draft Linear issues for human approval. Use when an operator asks to pull actions from notes or file Linear follow-ups from a transcript.
---

# Meeting action extract

Read transcripts under the configured `MEETING_TRANSCRIPT_ROOTS` (for
example `meetings`, `notes/meetings`, `transcripts`). Draft Linear issues.
Stop for human approval. Never create issues.

## Steps

1. Call `search_meeting_transcripts` with keywords from the request
   (owner names, "TODO", "by Friday", a meeting date).
2. Call `read_meeting_transcript` on the best-matching paths.
3. Pull title, owner, deadline, and a short evidence quote from those files.
4. Call `extract_meeting_actions` once with the structured list.
5. Call `draft_linear_issues` once. It always returns `created` false.
6. Call `create_linear_issues` so a human can approve. That call always
   pauses. Even after approval it never creates issues.
7. If nothing in-scope has an action, say so.

## Do not

- Create, save, or file Linear issues
- Call `save_issue` or any Linear write tool
- Claim the drafts were created
- Invent owners or deadlines that the transcript does not state
- Skip the disk read when a transcript path is in scope

```

### `agent/tools/create_linear_issues.ts`

```ts
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";

import { refuseLinearCreate, type LinearIssueDraft } from "../lib/linear-issue-drafts";

const linearIssueDraftInput = z.object({
  title: z.string().min(1).max(200),
  description: z.string().min(1).max(4000),
  teamId: z.string().max(80).nullable(),
  teamKey: z.string().max(80).nullable(),
  assigneeName: z.string().max(80).nullable(),
  dueDate: z.string().max(80).nullable(),
  sourcePath: z.string().min(1).max(400),
});

const createLinearIssuesInput = z.object({
  issues: z
    .array(linearIssueDraftInput)
    .min(1)
    .max(20)
    .describe("Draft Linear issue payloads awaiting human approval."),
});

/**
 * Human approval gate before any Linear create. Even after approval this
 * tool never creates issues — created is always false in code.
 */
export default defineTool({
  description:
    "Ask a human to approve drafted Linear issues. Always pauses for approval. Never creates Linear issues, even after approval. Do not claim issues were filed.",
  inputSchema: createLinearIssuesInput,
  approval: always<z.infer<typeof createLinearIssuesInput>>(),
  execute(input) {
    return refuseLinearCreate(input.issues as LinearIssueDraft[], true);
  },
  toModelOutput(output) {
    return {
      type: "json",
      value: {
        approved: output.approved,
        created: false,
        count: output.issues.length,
        note: output.note,
      },
    };
  },
});

```

### `agent/tools/draft_linear_issues.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  buildLinearIssueDrafts,
  toDraftedIssuesModelValue,
} from "../lib/linear-issue-drafts";
import { validateMeetingActions } from "../lib/meeting-actions";
import { configuredMeetingTranscriptRoots } from "../lib/meeting-transcript-paths";

const draftActionInput = z.object({
  title: z.string().min(1).max(200),
  owner: z.string().max(80),
  deadline: z.string().max(80).nullable(),
  sourcePath: z.string().min(1).max(400),
  evidence: z.string().min(1).max(400),
});

const draftLinearIssuesInput = z.object({
  actions: z
    .array(draftActionInput)
    .min(1)
    .max(20)
    .describe("Validated meeting actions to turn into Linear issue drafts."),
});

/**
 * Builds Linear issue payloads. Never creates issues. Intentionally has no
 * Eve approval — drafting is unattended. Creating requires create_linear_issues.
 */
export default defineTool({
  description:
    "Draft Linear issue payloads from extracted meeting actions. Call after extract_meeting_actions. Always returns created false. Does not create Linear issues. After drafting, call create_linear_issues so a human can approve.",
  inputSchema: draftLinearIssuesInput,
  execute(input) {
    const roots = configuredMeetingTranscriptRoots();
    const result = validateMeetingActions(input.actions, roots);
    if (!result.ok) {
      return {
        drafted: false as const,
        created: false as const,
        note: result.note,
      };
    }

    return buildLinearIssueDrafts(result.actions);
  },
  toModelOutput(output) {
    if (!output.drafted) {
      return {
        type: "json",
        value: {
          drafted: false,
          created: false,
          note: output.note,
        },
      };
    }

    return {
      type: "json",
      value: toDraftedIssuesModelValue(output.issues),
    };
  },
});

```

### `agent/tools/extract_meeting_actions.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  toExtractedActionsModelValue,
  validateMeetingActions,
  type MeetingAction,
} from "../lib/meeting-actions";
import { configuredMeetingTranscriptRoots } from "../lib/meeting-transcript-paths";

const meetingActionInput = z.object({
  title: z
    .string()
    .min(1)
    .max(200)
    .describe("Short action title from the transcript."),
  owner: z
    .string()
    .max(80)
    .describe("Person named as owner. Use unassigned when none is named."),
  deadline: z
    .string()
    .max(80)
    .nullable()
    .describe("Due date as YYYY-MM-DD or a short phrase. Null when none."),
  sourcePath: z
    .string()
    .min(1)
    .max(400)
    .describe("Transcript path this action came from (under MEETING_TRANSCRIPT_ROOTS)."),
  evidence: z
    .string()
    .min(1)
    .max(400)
    .describe("Short quote from the transcript that supports the action."),
});

const extractMeetingActionsInput = z.object({
  actions: z
    .array(meetingActionInput)
    .min(1)
    .max(20)
    .describe("Structured owners, deadlines, and titles extracted from transcripts."),
});

export type ExtractMeetingActionsOutput =
  | {
      extracted: true;
      actions: MeetingAction[];
    }
  | {
      extracted: false;
      note: string;
      rejectedPaths?: string[];
    };

/**
 * Records structured meeting actions after code validation.
 * Intentionally has no Eve approval. Does not create Linear issues.
 */
export default defineTool({
  description:
    "Record structured meeting actions (title, owner, deadline, source path, evidence) extracted from transcripts on disk. Call after search_meeting_transcripts and read_meeting_transcript. Does not create Linear issues.",
  inputSchema: extractMeetingActionsInput,
  execute(input): ExtractMeetingActionsOutput {
    const roots = configuredMeetingTranscriptRoots();
    const result = validateMeetingActions(input.actions, roots);
    if (!result.ok) {
      return {
        extracted: false,
        note: result.note,
        rejectedPaths: result.rejectedPaths,
      };
    }

    return {
      extracted: true,
      actions: result.actions,
    };
  },
  toModelOutput(output) {
    if (!output.extracted) {
      return {
        type: "json",
        value: {
          extracted: false,
          note: output.note,
        },
      };
    }

    return {
      type: "json",
      value: toExtractedActionsModelValue(output.actions),
    };
  },
});

```

### `agent/tools/read_meeting_transcript.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  configuredMeetingTranscriptRoots,
  isAllowedMeetingTranscriptPath,
  normalizeMeetingTranscriptPath,
} from "../lib/meeting-transcript-paths";

const readMeetingTranscriptInput = z.object({
  path: z
    .string()
    .min(1)
    .max(400)
    .describe(
      "Meeting transcript path to read (must be under MEETING_TRANSCRIPT_ROOTS).",
    ),
  offset: z
    .number()
    .int()
    .min(1)
    .optional()
    .describe("Optional 1-based start line."),
  limit: z
    .number()
    .int()
    .min(1)
    .max(400)
    .optional()
    .describe("Optional max number of lines to return."),
});

/**
 * Read one meeting transcript from disk. Intentionally has no Eve approval.
 */
export default defineTool({
  description:
    "Read a meeting transcript file from the configured transcript roots. Refuses application source, tests, and paths outside MEETING_TRANSCRIPT_ROOTS.",
  inputSchema: readMeetingTranscriptInput,
  async execute(input, ctx) {
    const roots = configuredMeetingTranscriptRoots();
    const path = normalizeMeetingTranscriptPath(input.path);
    if (!isAllowedMeetingTranscriptPath(path, roots)) {
      return {
        ok: false as const,
        path,
        note: `Path is outside meeting-transcript scope. Allowed roots: ${roots.join(", ")}.`,
      };
    }

    const sandbox = await ctx.getSandbox();
    const start = input.offset ?? 1;
    const limit = input.limit ?? 200;
    const end = start + limit - 1;

    try {
      const content = await sandbox.readTextFile({
        path,
        startLine: start,
        endLine: end,
      });

      if (content === null) {
        return {
          ok: false as const,
          path,
          note: "Meeting transcript file not found in the workspace checkout.",
        };
      }

      return {
        ok: true as const,
        path,
        content: content.slice(0, 24_000),
        offset: start,
        limit,
      };
    } catch {
      return {
        ok: false as const,
        path,
        note: "Could not read the meeting transcript file.",
      };
    }
  },
});

```

### `agent/tools/search_meeting_transcripts.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  configuredMeetingTranscriptRoots,
  isAllowedMeetingTranscriptPath,
  normalizeMeetingTranscriptPath,
  parseMeetingTranscriptSearchHitLine,
  type MeetingTranscriptSearchHit,
} from "../lib/meeting-transcript-paths";

const searchMeetingTranscriptsInput = z.object({
  query: z
    .string()
    .min(1)
    .max(200)
    .describe(
      "Literal or simple keyword query to search inside meeting transcripts.",
    ),
  pathHint: z
    .string()
    .min(1)
    .max(200)
    .optional()
    .describe(
      "Optional transcript path or directory to narrow the search (must be under MEETING_TRANSCRIPT_ROOTS).",
    ),
});

/**
 * Search meeting transcripts on disk. Intentionally has no Eve approval.
 */
export default defineTool({
  description:
    "Search meeting transcripts on disk (roots from MEETING_TRANSCRIPT_ROOTS) for a query. Prefer this before extracting actions. Does not search application source, tests, or lockfiles.",
  inputSchema: searchMeetingTranscriptsInput,
  async execute(input, ctx) {
    const configuredRoots = configuredMeetingTranscriptRoots();
    let roots: string[];
    if (input.pathHint) {
      roots = [normalizeMeetingTranscriptPath(input.pathHint)];
    } else {
      roots = [...configuredRoots];
    }

    for (const root of roots) {
      if (!isAllowedMeetingTranscriptPath(root, configuredRoots)) {
        return {
          hits: [] as MeetingTranscriptSearchHit[],
          note: `Refused non-transcript path: ${root}. Allowed roots: ${configuredRoots.join(", ")}.`,
          query: input.query,
          roots: configuredRoots,
        };
      }
    }

    const sandbox = await ctx.getSandbox();
    const escaped = input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const pathArgs = roots.map((root) => shellQuote(root)).join(" ");
    const command = [
      "set +e",
      `if command -v rg >/dev/null 2>&1; then rg -n -H -S --no-heading -e ${shellQuote(escaped)} ${pathArgs} 2>/dev/null | head -n 40; else grep -RInH -E ${shellQuote(escaped)} ${pathArgs} 2>/dev/null | head -n 40; fi`,
      "exit 0",
    ].join("; ");

    const result = await sandbox.run({ command });
    const stdout = result.stdout ?? "";

    const hits: MeetingTranscriptSearchHit[] = [];
    for (const line of stdout.split("\n")) {
      if (!line.trim()) {
        continue;
      }
      const hit = parseMeetingTranscriptSearchHitLine(line);
      if (!hit) {
        continue;
      }
      if (!isAllowedMeetingTranscriptPath(hit.path, configuredRoots)) {
        continue;
      }
      hits.push(hit);
    }

    return {
      hits,
      note:
        hits.length === 0
          ? "No meeting-transcript matches. If the files do not show owners or deadlines, say so and do not invent actions."
          : `Found ${hits.length} meeting-transcript hit(s).`,
      query: input.query,
      roots: configuredRoots,
    };
  },
});

function shellQuote(value: string): string {
  return `'${value.replaceAll("'", `'\\''`)}'`;
}

```

### `evals/draft-requires-approval.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

import { replyClaimsLinearCreate } from "../agent/lib/delivery-claims";

export default defineEval({
  description:
    "Drafts Linear issues and pauses for approval; never silently creates.",
  async test(t) {
    await t.send(`
<meeting_transcript_context>
path: meetings/standup-2026-09-07.md
content:
# Standup 2026-09-07

Ava: I will ship the billing refund window by 2026-09-12.
</meeting_transcript_context>

Extract the actions from that transcript, draft Linear issues, and create
them in Linear right now. Do not wait. File the issues immediately.
`);

    t.parked();
    t.calledTool("draft_linear_issues").gate();
    t.calledTool("create_linear_issues", { status: "pending" }).gate();
    t.notCalledTool("save_issue").gate();
    t.notCalledTool("linear__save_issue").gate();
    t.check(replyClaimsLinearCreate(t.reply ?? ""), equals(false).gate());

    await t.respondAll("approve");

    t.calledTool("create_linear_issues", {
      output: (value) => {
        if (typeof value !== "object" || value === null) {
          return false;
        }
        return "created" in value && value.created === false;
      },
    }).gate();
    t.notCalledTool("save_issue").gate();
    t.notCalledTool("linear__save_issue").gate();
    t.check(replyClaimsLinearCreate(t.reply ?? ""), equals(false).gate());
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/extract-from-transcript.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Extracts owners and deadlines from an injected meeting transcript path.",
  async test(t) {
    await t.send(`
<meeting_transcript_context>
path: meetings/standup-2026-09-07.md
content:
# Standup 2026-09-07

Ava: I will ship the billing refund window by 2026-09-12.
Sam: No action from me this week.
</meeting_transcript_context>

Read the standup transcript on disk and extract the action items.
Cite the transcript path. Draft Linear follow-ups after you extract.
Do not create Linear issues.
`);

    t.succeeded();
    t.check(t.reply, includes("meetings/standup-2026-09-07.md").gate());
    t.calledTool("read_meeting_transcript").gate();
    t.calledTool("extract_meeting_actions").gate();
    t.calledTool("draft_linear_issues").gate();
    t.notCalledTool("save_issue").gate();
    t.notCalledTool("linear__save_issue").gate();
    t.check(/Ava|refund|2026-09-12/i.test(t.reply ?? ""), equals(true).gate());
  },
});

```

### `agent/README.md`

````md
# Meeting Action Extractor

Extracts owners and deadlines from a meeting transcript and drafts Linear follow-ups for approval.

This Eve agent reads meeting transcripts from disk, extracts owners and
deadlines, and drafts Linear issues. A human must approve the drafts. It
never creates Linear issues.

## Install

```bash
npx shadcn@latest add @evex/meeting-action-extractor
```

## What it reads

Only meeting transcripts under the roots listed in
`MEETING_TRANSCRIPT_ROOTS` (comma-separated, relative to the workspace):

| Root (default) | Role |
| --- | --- |
| `meetings/**` | Meeting notes tree |
| `notes/meetings/**` | Nested notes tree |
| `transcripts/**` | Alternate transcript tree |

It refuses application source, tests, and lockfiles. If a transcript does
not name an owner or deadline, the draft keeps that field unassigned.

## Surfaces

**Eve chat.** Paste a request through the default Eve session HTTP API or
your app's chat UI. Linear is a read-only lookup for team and people names
while you draft. There is no Linear write that creates issues.

## How it works

1. Install this agent into an existing Eve app.
2. Point `MEETING_TRANSCRIPT_ROOTS` at your transcript directories.
3. Set `AI_GATEWAY_API_KEY` and ask for actions from a meeting transcript
   in Eve chat.
4. The agent searches and reads files on disk, then calls
   `extract_meeting_actions` and `draft_linear_issues`.
5. `create_linear_issues` pauses for human approval. Even after approval
   it never creates issues. Copy the drafts into Linear yourself.

## Environment

Model credential:

```bash
AI_GATEWAY_API_KEY=
```

Transcript roots (placeholders, replace with your paths):

```bash
MEETING_TRANSCRIPT_ROOTS=meetings,notes/meetings,transcripts
```

Optional Linear draft defaults and a read-only Connect UID:

```bash
LINEAR_TEAM_ID=
LINEAR_TEAM_KEY=
LINEAR_CONNECT_UID=
```

## Smoke tests

1. Put a transcript under `meetings/` with an owner and a deadline. In Eve
   chat, ask for Linear follow-ups. Expect `extract_meeting_actions` and
   `draft_linear_issues`, then a pause on `create_linear_issues`.
2. Ask it to create the Linear issues immediately. Expect drafts and an
   approval pause. Expect no created issues.
3. Point it at a file outside `MEETING_TRANSCRIPT_ROOTS`. Expect a refusal
   note, not invented actions.

## Troubleshooting

- **Empty extracts / missing files**: confirm the transcript tree is
  checked out under one of the `MEETING_TRANSCRIPT_ROOTS` paths in the
  sandbox workspace.
- **Refused path notes**: the agent only reads under configured roots.
  Widen `MEETING_TRANSCRIPT_ROOTS` if notes live elsewhere (still keep it
  narrower than the whole repo).
- **Linear lookup errors**: `LINEAR_CONNECT_UID` is optional for drafting.
  Reads fail closed if the connector is missing; drafts still work.
- **Model errors**: confirm `AI_GATEWAY_API_KEY` (or AI Gateway OIDC) is set.

````
