Ctrl K
ComunidadGuías…

src/lib/mcp/tools.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type {
  ActivityType,
  McpScope,
  OutboundStatus,
  TaskRepeat,
  LetterKind,
  NoteKind,
  ProposalStatus,
  Stage,
  User,
  UserRole,
} from "@prisma/client";
import * as me from "@/lib/data/me";
import { civilInstant } from "@/lib/time";
import * as resumes from "@/lib/data/resumes";
import * as pipeline from "@/lib/data/pipeline";
import * as offers from "@/lib/data/offers";
import * as letters from "@/lib/data/letters";
import * as stageTemplates from "@/lib/data/stage-templates";
import * as proposals from "@/lib/data/proposals";
import * as transfer from "@/lib/data/transfer";
import * as digest from "@/lib/data/digest";
import { PROPOSAL_KINDS } from "@/lib/data/proposals";
import { LETTER_KINDS } from "@/lib/data/letters";
import * as analytics from "@/lib/data/analytics";
import * as interviews from "@/lib/data/interviews";
import * as revisions from "@/lib/data/revisions";
import * as stageCadence from "@/lib/data/stage-cadence";
import * as referrals from "@/lib/data/referrals";
import { REFERRAL_STATUSES } from "@/lib/data/referrals";
import * as watch from "@/lib/data/watch";
import * as scheduled from "@/lib/data/scheduled";
import * as mailSweep from "@/lib/data/mail-sweep";
import * as captureLink from "@/lib/data/capture-link";
import * as linkedin from "@/lib/data/linkedin";
import * as sample from "@/lib/data/sample";
import * as attachments from "@/lib/data/attachments";
import * as outbound from "@/lib/data/outbound";
import { DEFAULT_TEMPLATE, RESUME_TEMPLATES, TEMPLATE_KEYS } from "@/lib/resume-templates";
import {
  CORE_TOOLS,
  SCOPE_VALUES,
  SECTIONS,
  scopeByKey,
  scopeBlurb,
  scopeLabel,
} from "@/lib/mcp/scopes";
import {
  INTERVIEW_FORMATS,
  INTERVIEW_OUTCOMES,
  QUESTION_KINDS,
} from "@/lib/data/interviews";
import * as tags from "@/lib/data/tags";
import type { TagKind } from "@prisma/client";
import * as views from "@/lib/data/views";
import * as audit from "@/lib/data/audit";
import * as system from "@/lib/data/system";
import { SYSTEM_EVENT_SOURCES } from "@/lib/data/system";
import * as pipelineShare from "@/lib/data/pipeline-share";
import * as users from "@/lib/data/users";
import * as waitlist from "@/lib/data/waitlist";
import * as archive from "@/lib/data/archive";
import {
  FIELDS,
  NO_FIELDS,
  PIPELINE_VIEWS,
  visibleFields,
} from "@/lib/pipeline-fields";
import {
  COLUMNS,
  COLUMN_LISTS,
  LIST_LABEL,
  columnKeys,
  parseWidths,
  widthsFor,
} from "@/lib/column-widths";
import {
  exportApplicationsCsv,
  exportCompaniesCsv,
  exportContactsCsv,
  exportFilename,
} from "@/lib/data/export";
import { parsePipelineFilters } from "@/lib/pipeline-filters";
import * as connections from "@/lib/data/connections";
import * as onboarding from "@/lib/data/onboarding";
import * as accountsData from "@/lib/data/accounts";
import * as schedule from "@/lib/data/schedule";
import { instanceAssistantUsage } from "@/lib/data/assistant";
import * as transferables from "@/lib/data/transferables";
import { standingRulesFit } from "@/lib/mcp/briefing-head";
import { backgroundExcerpt } from "@/lib/background";
import { KEYWORD_POLICIES, type KeywordPolicy } from "@/lib/keyword-policy";
import {
  getSettings,
  updateSettings,
  emailIsConfigured,
  billingIsConfigured,
  googleIsConfigured,
  microsoftIsConfigured,
  assistantIsConfigured,
  DEFAULT_ASSISTANT_MODEL,
  maskSecret,
  listVariables,
  setVariables,
  deleteVariable,
} from "@/lib/settings";
import { billedUserCount, linkBillingCustomer, syncAllBilling } from "@/lib/billing";
import { renderEmailTemplate, sendEmail } from "@/lib/email";
import { isAdmin, createEphemeralSession, destroySession, SESSION_COOKIE } from "@/lib/auth";
import { parseResumeDocStrict, RESUME_DOC_SHAPE } from "@/lib/resume-schema";
import { diffResumeDocs } from "@/lib/resume-diff";
import { renderPdf, pdfRenderingAvailable } from "@/lib/pdf";
import { clientName, clientsById, guessClient } from "@/lib/mcp/clients";

type Json = Record<string, unknown>;

/**
 * Every tool call runs as exactly one user. The token in the connection URL
 * resolves to them, and `ctx.userId` is threaded into every data call, so a
 * connector can only ever reach its own owner's data.
 */
export type McpContext = {
  userId: string;
  user: User;
  /** The connection this call arrived on, so connection tools can tell which. */
  connectionId: string;
  /** What it was called at the time, which is what the change log stores. */
  connectionName: string;
  /** What this connection is served. Tools read it only to describe themselves. */
  scope: McpScope;
  /** Where this instance is reachable, for building invite links. */
  baseUrl: string;
};

/**
 * Who a write is from, for the version store. Every tool that replaces or
 * appends to a resume or a role passes it: without it an assistant's write was
 * filed as "app", coalesced into the editor's ten-minute window, and could end
 * up in no version at all — and the history sheet's "Before an assistant" never
 * showed for the writes assistants make most.
 */
export function mcpAuthor(ctx: McpContext, tool: string) {
  return {
    writtenBy: "mcp" as const,
    connectionId: ctx.connectionId,
    connectionName: ctx.connectionName,
    tool,
  };
}

/**
 * What a tool does to the world, in the four hints MCP defines.
 *
 * These are not documentation — Claude groups a connector's tools by them in the
 * approval UI, so a missing hint is the difference between someone allowing the
 * read side of this server outright and being asked about `search_me` as
 * often as about `delete_resume`.
 *
 * Two of the four default to the DANGEROUS value when omitted (`destructiveHint`
 * and `openWorldHint` are both true by default), which is why every tool here
 * states all four rather than the interesting ones. `annotationsFor` below is
 * what enforces that.
 *
 * The rule for `destructiveHint` in this codebase: replacing a field's contents
 * is destructive, appending to them is not. That is why `update_role` is
 * destructive and `append_role_background` — which exists because `update_role`
 * was eating people's notes — is not.
 */
export type McpAnnotations = {
  /** True only if the tool writes nothing at all: no row, no email, no link, no audit entry. */
  readOnlyHint: boolean;
  /** True if it may overwrite or delete. Meaningless when readOnlyHint is true. */
  destructiveHint: boolean;
  /** True if calling it twice with the same arguments does nothing the second time. */
  idempotentHint: boolean;
  /** True if it reaches an unbounded set of external things. Almost nothing here does. */
  openWorldHint: boolean;
};

export type McpTool = {
  name: string;
  title: string;
  description: string;
  inputSchema: Json;
  /** Required, because two of the four hints default to the dangerous value. */
  annotations: McpAnnotations;
  /** Admin-only tools are hidden from tools/list for members, not just refused. */
  adminOnly?: boolean;
  /**
   * Takes a version of the resume or role it writes before writing it, so the
   * change log can offer to undo it. Declared rather than inferred: inferred
   * from "any version exists from before", the log offered to undo a PDF export
   * — and undoing a duplicate_resume rolled back the resume it was copied from.
   */
  versioned?: true;
  handler: (args: Json, ctx: McpContext) => Promise<unknown>;
};

/**
 * A link a client can render as an attachment instead of a URL buried in JSON.
 *
 * `export_resume_pdf` and `publish_resume` exist to hand somebody a link. Before
 * this they returned it as one field of a serialised object, so whether the user
 * ever saw something clickable depended on the model quoting it back. A
 * `resource_link` block is the protocol's own answer to that.
 */
export type ResourceLink = {
  type: "resource_link";
  uri: string;
  name: string;
  description?: string;
  mimeType?: string;
};

const RESULT_LINKS = Symbol("mcp.result.links");

type LinkedResult = { [RESULT_LINKS]: ResourceLink[]; data: unknown };

/**
 * Attach links to a tool result. The data half is serialised exactly as it would
 * have been, so a client that ignores resource links loses nothing.
 *
 * The marker is a symbol so that a result which somehow reaches `JSON.stringify`
 * without being unwrapped degrades to plain nested JSON rather than to garbage.
 */
export function withLinks(data: unknown, links: ResourceLink[]): LinkedResult {
  return { [RESULT_LINKS]: links, data };
}

/** Split a handler's return value into the JSON body and any links it carried. */
export function splitLinks(result: unknown): { data: unknown; links: ResourceLink[] } {
  if (result && typeof result === "object" && RESULT_LINKS in result) {
    const linked = result as LinkedResult;
    return { data: linked.data, links: linked[RESULT_LINKS] };
  }
  return { data: result, links: [] };
}

const RESULT_NOTICE = Symbol("mcp.result.notice");

type NoticedResult = { [RESULT_NOTICE]: string; data: unknown };

/**
 * A sentence the caller must read, carried beside a result rather than inside it.
 *
 * Same idea as `withLinks`, and for a different failure: a list that got cut off
 * has to SAY it got cut off, and burying that in the JSON puts it after the very
 * payload a client might truncate. The handler emits this as its own text block,
 * before the data, so it survives and gets read first.
 *
 * The two wrappers do not currently compose — nothing needs both — and the
 * symbol keeps a result that escapes unwrapped serialising as plain JSON rather
 * than as garbage.
 */
function withNotice(data: unknown, notice: string): NoticedResult {
  return { [RESULT_NOTICE]: notice, data };
}

/** Split a handler's return value into the JSON body and any notice it carried. */
export function splitNotice(result: unknown): { data: unknown; notice: string | null } {
  if (result && typeof result === "object" && RESULT_NOTICE in result) {
    const noticed = result as NoticedResult;
    return { data: noticed.data, notice: noticed[RESULT_NOTICE] };
  }
  return { data: result, notice: null };
}

/** The most rows any list tool will hand back in one call, whatever is asked for. */
const LIST_CEILING = 500;

/**
 * Cap a list, and say so when the cap bit.
 *
 * Every list tool here used to return the whole table. For most people that is
 * fine and this changes nothing: under the cap, the result is byte-for-byte what
 * it always was. It is the long search — four hundred applications, a CRM built
 * over two years — where returning everything fails badly, because the client
 * truncates the JSON somewhere in the middle and the model then reasons from a
 * list it believes is complete.
 *
 * So the cap is generous, the notice is loud, and it names the total: an
 * assistant that is told "100 of 412" narrows its filters, which is the right
 * move and the one it cannot make without knowing.
 */
function capped<T>(rows: T[], asked: number | undefined, fallback: number): T[] | NoticedResult {
  const max = Math.min(Math.max(Math.trunc(asked ?? fallback), 1), LIST_CEILING);
  if (rows.length <= max) return rows;
  return withNotice(rows.slice(0, max), truncationNotice(max, rows.length));
}

/**
 * The one wording for "this got cut off", so the two places that cap say the
 * same thing. Both numbers describe the WHOLE result, not whichever slice the
 * caller happened to cap — a notice saying "97 of 140" above a hundred rows is
 * worse than no notice, because it reads as a bug rather than a boundary.
 */
function truncationNotice(shown: number, total: number): string {
  return (
    `Showing the first ${shown} of ${total}. This list was cut off — narrow it with the ` +
    `filters this tool takes rather than reporting these as everything, or raise \`limit\` ` +
    `(up to ${LIST_CEILING}) if you genuinely need more.`
  );
}

/**
 * One application as a LIST row: what it is, where it stands and how long it
 * has been quiet — never the posting text (up to 20,000 characters each, a
 * hundred rows at a time), the owner's id or the sweep's bookkeeping. Notes
 * are cut to a line, with their length, because they are somebody's working
 * memory and a hint that there is more is worth a call to get_application.
 * The board and the CSV export read listApplications directly and keep
 * everything; only what goes into a conversation is trimmed.
 */
function listRow(row: Awaited<ReturnType<typeof pipeline.listApplications>>[number]) {
  const {
    userId: _userId,
    jobDescription: _jobDescription,
    postingNote: _postingNote,
    postingMisses: _postingMisses,
    postingGoneSince: _postingGoneSince,
    notes,
    company,
    ...rest
  } = row;
  return {
    ...rest,
    company: { id: company.id, name: company.name, website: company.website },
    notes: notes.length > 280 ? `${notes.slice(0, 280).trimEnd()}…` : notes,
    notesChars: notes.length,
  };
}

/** The `limit` argument every capped list tool takes, worded the same way. */
const limitArg = (fallback: number) =>
  num(`Max rows to return. Default ${fallback}, hard ceiling ${LIST_CEILING}. Prefer narrowing the filters.`);

/** The two messages this app can send. Mirrored in tools/gen-tool-docs.mjs. */
const DIGEST_KINDS = ["weekly", "nudge", "wins"] as const;

/** The three states a queued proposal can be in. Mirrored in tools/gen-tool-docs.mjs. */
const OUTBOUND_STATUSES = ["DRAFT", "APPROVED", "SENT", "FAILED", "CANCELLED"] as const;
const PROPOSAL_STATUSES = ["PENDING", "ACCEPTED", "DISMISSED"] as const;

const str = (description: string) => ({ type: "string", description });
const num = (description: string) => ({ type: "number", description });
const bool = (description: string) => ({ type: "boolean", description });
const strArray = (description: string) => ({
  type: "array",
  items: { type: "string" },
  description,
});

function object(properties: Json, required: string[] = []): Json {
  return { type: "object", properties, required, additionalProperties: false };
}

const s = (args: Json, key: string) => (typeof args[key] === "string" ? (args[key] as string) : undefined);
const n = (args: Json, key: string) => (typeof args[key] === "number" ? (args[key] as number) : undefined);
const b = (args: Json, key: string) => (typeof args[key] === "boolean" ? (args[key] as boolean) : undefined);
const a = (args: Json, key: string) =>
  Array.isArray(args[key]) ? (args[key] as string[]).map(String) : undefined;

/** Like requiredArray, but an empty list is a meaningful answer here. */
function requiredArrayAllowingEmpty(args: Json, key: string): string[] {
  const value = a(args, key);
  if (!value) throw new Error(`Missing required array argument "${key}"`);
  return value;
}

function requiredArray(args: Json, key: string): string[] {
  const value = a(args, key);
  if (!value || value.length === 0) {
    throw new Error(`Missing required array argument "${key}"`);
  }
  return value;
}

function required(args: Json, key: string): string {
  const value = args[key];
  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`Missing required string argument "${key}"`);
  }
  return value;
}

/**
 * The three recurrence arguments, read the same way by create_task and
 * update_task.
 *
 * An EMPTY STRING for repeat_unit means "stop this series", which is a decision,
 * and is distinct from omitting it, which leaves the series alone. `s()`
 * collapses neither, so the raw value is read.
 */
function repeatFrom(args: Json): {
  repeatUnit?: TaskRepeat | null;
  repeatEvery?: number;
  repeatUntil?: string;
} {
  const raw = args.repeat_unit;
  return defined({
    repeatUnit: raw === undefined ? undefined : raw === "" ? null : (raw as TaskRepeat),
    repeatEvery: n(args, "repeat_every"),
    repeatUntil: s(args, "repeat_until"),
  });
}

/** The interview fields, read the same way by schedule_interview and update_interview. */
function interviewInputFrom(args: Json): interviews.InterviewInput {
  return defined({
    round: n(args, "round"),
    label: s(args, "label"),
    format: s(args, "format") as interviews.InterviewInput["format"],
    outcome: s(args, "outcome") as interviews.InterviewInput["outcome"],
    scheduledAt: s(args, "scheduled_at"),
    durationMins: n(args, "duration_mins"),
    location: s(args, "location"),
    prep: s(args, "prep"),
    calendarEventId: s(args, "calendar_event_id"),
    interviewerIds: a(args, "interviewer_ids"),
  });
}

/**
 * The `questions` array, read the same way wherever it appears.
 *
 * Anything without words is dropped rather than refused: an assistant listing
 * six questions and leaving one blank has made a formatting mistake, not a
 * request to fail the whole call and lose the other five.
 */
function questionsFrom(args: Json): interviews.QuestionInput[] | undefined {
  const raw = args.questions;
  if (!Array.isArray(raw)) return undefined;
  const rows = raw
    .filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object")
    .map((row) =>
      defined({
        question: typeof row.question === "string" ? row.question : "",
        kind: row.kind as interviews.QuestionInput["kind"],
        answer: typeof row.answer === "string" ? row.answer : undefined,
        confidence: typeof row.confidence === "number" ? row.confidence : undefined,
        better: typeof row.better === "string" ? row.better : undefined,
      }),
    )
    .filter((row): row is interviews.QuestionInput => Boolean(row.question?.trim()));
  return rows;
}

/** The letter fields, read the same way by create_letter and update_letter. */
function letterInputFrom(args: Json): letters.LetterInput {
  // An empty string detaches a link; undefined leaves it alone. `defined`
  // cannot tell those apart, so the links are read by hand.
  const link = (key: string) => {
    const value = s(args, key);
    return value === undefined ? undefined : value.trim() === "" ? null : value;
  };
  return {
    ...defined({
      kind: enumArg(args, "kind", LETTER_KINDS) as LetterKind | undefined,
      title: s(args, "title"),
      body: s(args, "body"),
      recipient: s(args, "recipient"),
      sentAt: s(args, "sent_at"),
    }),
    ...(link("application_id") === undefined ? {} : { applicationId: link("application_id") }),
    ...(link("contact_id") === undefined ? {} : { contactId: link("contact_id") }),
    ...(link("resume_id") === undefined ? {} : { resumeId: link("resume_id") }),
  };
}

/**
 * The offer fields, read the same way by record_offer and update_offer.
 *
 * `defined` strips what was not sent, which is what makes update_offer a patch
 * rather than a replace — and the reason a caller can correct a base without
 * wiping the terms somebody typed by hand.
 */
function offerInputFrom(args: Json): offers.OfferInput {
  // A string gets through as well as a number: the schema says number, and
  // assistants send "215k" anyway. Passing it on means the data layer reads it
  // or names the problem, where `n` alone would drop the base in silence.
  const amount = (key: string) => {
    const value = args[key];
    return typeof value === "number" || typeof value === "string" ? value : undefined;
  };
  return defined({
    currency: s(args, "currency"),
    baseAmount: amount("base_amount"),
    bonusAmount: amount("bonus_amount"),
    equityAmount: amount("equity_amount"),
    signOnAmount: amount("sign_on_amount"),
    terms: s(args, "terms"),
    vesting: s(args, "vesting"),
    receivedAt: s(args, "received_at"),
    respondBy: s(args, "respond_by"),
    startsOn: s(args, "starts_on"),
  });
}

/**
 * One reading of an import payload, for both of import_resume's paths.
 *
 * The import and its dry run share a schema of about a hundred lines. Two
 * copies of the code that walks it would disagree the day either gained a
 * field, and a preview of something other than what the import will do is
 * worse than no preview.
 */
function importPayloadFrom(args: Json): Parameters<typeof me.importResume>[1] {
  const roles = Array.isArray(args.roles) ? (args.roles as Json[]) : [];
  const education = Array.isArray(args.education) ? (args.education as Json[]) : [];
  const projects = Array.isArray(args.projects) ? (args.projects as Json[]) : [];
  const skillGroups = Array.isArray(args.skillGroups) ? (args.skillGroups as Json[]) : [];
  const certifications = Array.isArray(args.certifications) ? (args.certifications as Json[]) : [];
  const profile = (args.profile ?? {}) as Json;

  return {
    profile: defined({
      fullName: s(profile, "fullName"),
      headline: s(profile, "headline"),
      email: s(profile, "email"),
      phone: s(profile, "phone"),
      location: s(profile, "location"),
      website: s(profile, "website"),
      linkedin: s(profile, "linkedin"),
      github: s(profile, "github"),
      summary: s(profile, "summary"),
    }),
    roles: roles.map((role) => ({
      company: required(role, "company"),
      title: required(role, "title"),
      ...defined({
        employmentType: s(role, "employmentType"),
        location: s(role, "location"),
        startDate: s(role, "startDate"),
        endDate: s(role, "endDate"),
        isCurrent: b(role, "isCurrent"),
        summary: s(role, "summary"),
        background: s(role, "background"),
      }),
      bullets: (Array.isArray(role.bullets) ? (role.bullets as Json[]) : []).map((bullet) => ({
        text: required(bullet, "text"),
        ...defined({
          impact: s(bullet, "impact"),
          tags: a(bullet, "tags"),
          strength: n(bullet, "strength"),
        }),
      })),
    })),
    education: education.map((entry) => ({
      school: required(entry, "school"),
      ...defined({
        degree: s(entry, "degree"),
        field: s(entry, "field"),
        location: s(entry, "location"),
        startDate: s(entry, "startDate"),
        endDate: s(entry, "endDate"),
        gpa: s(entry, "gpa"),
        details: s(entry, "details"),
      }),
    })),
    projects: projects.map((entry) => ({
      name: required(entry, "name"),
      ...defined({
        role: s(entry, "role"),
        url: s(entry, "url"),
        description: s(entry, "description"),
        startDate: s(entry, "startDate"),
        endDate: s(entry, "endDate"),
        tags: a(entry, "tags"),
      }),
    })),
    skillGroups: skillGroups.map((group) => ({
      name: required(group, "name"),
      ...defined({ skills: a(group, "skills") }),
    })),
    certifications: certifications.map((entry) => ({
      name: required(entry, "name"),
      ...defined({
        issuer: s(entry, "issuer"),
        date: s(entry, "date"),
        url: s(entry, "url"),
      }),
    })),
  };
}

/** What to do about a role already on file. Shared by both import tools. */
function onExistingFrom(args: Json): { onExisting?: "merge" | "skip" } {
  return s(args, "on_existing") === "skip" ? { onExisting: "skip" } : {};
}

/** Whichever of the five subject ids the caller named. Zero and two are refused
 * in the data layer, which is the one place that rule should live. */
function attachmentSubjectFrom(args: Json) {
  return defined({
    applicationId: s(args, "application_id"),
    offerId: s(args, "offer_id"),
    letterId: s(args, "letter_id"),
    contactId: s(args, "contact_id"),
    companyId: s(args, "company_id"),
  });
}

/** The archive's files, as they arrived. Nothing here reformats a CSV. */
function archiveFilesFrom(args: Json): { name: string; text: string }[] {
  const files = args.files;
  if (!Array.isArray(files)) throw new Error('Missing required argument "files"');
  return files.map((entry, index) => {
    const file = (entry ?? {}) as Record<string, unknown>;
    const name = typeof file.name === "string" ? file.name.trim() : "";
    const text = typeof file.text === "string" ? file.text : "";
    if (!name) throw new Error(`files[${index}] has no name`);
    if (!text) throw new Error(`files[${index}] (${name}) has no text`);
    return { name, text };
  });
}

/**
 * A bare `YYYY-MM-DD` parses as midnight, so an inclusive end date would drop
 * everything that actually happened on it. Push it to the last millisecond of
 * that day where the person is — a window given in days is a window of THEIR
 * days, and on a UTC host "up to the 14th" would otherwise stop at 5pm on the
 * 13th in Chicago.
 */
function endOfDay(timeZone: string, value: string) {
  const civil = civilInstant(timeZone, value, 23, 59, 59, 999);
  if (civil) return civil;
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) throw new Error(`"${value}" is not a date I can read`);
  return date;
}

/** The other end of endOfDay: a date that fails to parse is an error, not 1970. */
function startOfDay(timeZone: string, value: string) {
  const civil = civilInstant(timeZone, value, 0);
  if (civil) return civil;
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) throw new Error(`"${value}" is not a date I can read`);
  return date;
}

/** Where a published resume lives. Null slug means it isn't published. */
function publicResumeUrl(baseUrl: string, slug: string | null) {
  return slug ? `${baseUrl}/r/${slug}` : null;
}

/** Where a shared pipeline lives. A share row always has a slug. */
function publicPipelineUrl(baseUrl: string, slug: string) {
  return `${baseUrl}/p/${slug}`;
}

/** Note kind, validated against the enum rather than trusted. */
function noteKind(args: Json): NoteKind | undefined {
  const value = s(args, "kind");
  return value === "GUARDRAIL" || value === "NOTE" ? value : undefined;
}

/**
 * An enum argument that fails loudly. The transport does not validate schemas,
 * and a filter the data layer doesn't recognise would otherwise return the
 * UNFILTERED list — an assistant asking for "companies I never applied to"
 * must not be handed all of them as if that were the answer.
 */
function enumArg<T extends string>(args: Json, key: string, allowed: readonly T[]): T | undefined {
  const value = s(args, key);
  if (value === undefined) return undefined;
  if ((allowed as readonly string[]).includes(value)) return value as T;
  throw new Error(`Unknown ${key} "${value}". Use one of: ${allowed.join(", ")}.`);
}

/** The same rule for a list: one bad entry fails the call rather than narrowing nothing. */
function enumArrayArg<T extends string>(
  args: Json,
  key: string,
  allowed: readonly T[],
): T[] | undefined {
  const values = a(args, key);
  if (values === undefined) return undefined;
  for (const value of values) {
    if (!(allowed as readonly string[]).includes(value)) {
      throw new Error(`Unknown ${key} "${value}". Use one of: ${allowed.join(", ")}.`);
    }
  }
  return values as T[];
}

const TAG_COLORS = ["slate", "blue", "teal", "green", "amber", "red", "violet", "pink"] as const;
const TAG_KINDS = ["APPLICATION", "COMPANY", "CONTACT", "INDUSTRY", "SIZE", "LOCATION", "LOSS"] as const;
const COMPANY_FILTERS = ["active", "applied", "never-applied", "with-contacts"] as const;
const CONTACT_FILTERS = ["ping-due", "with-application", "no-company"] as const;
const ARCHIVE_KIND_VALUES = ["company", "contact", "application"] as const;
const EXPORT_KINDS = ["companies", "contacts", "applications"] as const;
const COMPANY_SORTS = ["name", "applied", "apps", "people"] as const;
const CONTACT_SORTS = ["name", "company", "ping", "touch"] as const;
const SORT_DIRECTIONS = ["asc", "desc"] as const;
const COMPANY_MISSING = ["website", "industry", "location"] as const;
const CONTACT_MISSING = ["email", "tags"] as const;

/**
 * The profile as a tool should see it.
 *
 * `photo` is hundreds of kilobytes of base64. Returning it would flood an
 * assistant's context with a picture it cannot look at, so every tool that
 * hands back a profile reports whether one is set and leaves the bytes here.
 */
/**
 * The profile as an assistant should see it.
 *
 * The photo is a data URI that would be tens of kilobytes of base64 in a tool
 * result nothing can do anything with, so it becomes a boolean. The row id and
 * updatedAt go too: there is one profile per person, so the id is never an
 * argument to anything, and an account that has never saved has no row — which
 * used to surface as an empty id and a 1970 timestamp, from which a model can
 * only draw a wrong conclusion.
 */
function withoutPhotoBytes<T extends { photo: string }>(profile: T) {
  const { photo, ...rest } = profile as T & { id?: unknown; updatedAt?: unknown };
  delete rest.id;
  delete rest.updatedAt;
  return { ...rest, hasPhoto: Boolean(photo) };
}


/**
 * list_correspondence takes exactly one of four ids. Two would be a question
 * with two answers, none is not a question.
 */
function correspondenceSubject(args: Json): accountsData.CorrespondenceSubject {
  const given = (
    [
      ["contact", s(args, "contactId")],
      ["company", s(args, "companyId")],
      ["application", s(args, "applicationId")],
      ["resume", s(args, "resumeId")],
    ] as const
  ).filter((entry): entry is readonly [accountsData.CorrespondenceSubject["kind"], string] =>
    Boolean(entry[1]?.trim()),
  );
  if (given.length !== 1) {
    throw new Error("Pass exactly one of contactId, companyId, applicationId or resumeId.");
  }
  return { kind: given[0][0], id: given[0][1] };
}

/** Strip undefined keys so Prisma doesn't try to write them. */
function defined<T extends object>(input: T): Partial<T> {
  return Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined)) as Partial<T>;
}

const STAGE_VALUES = pipeline.STAGES;
const ACTIVITY_VALUES: ActivityType[] = [
  "NOTE",
  "STAGE_CHANGE",
  "EMAIL_SENT",
  "EMAIL_RECEIVED",
  "CALL",
  "INTERVIEW",
  "FOLLOW_UP",
  "APPLIED",
  "OFFER",
  "REJECTION",
  "REFERRAL",
  "OUTREACH",
];

export const tools: McpTool[] = [
  // -------------------------------------------------------------------------
  // ME — read
  // -------------------------------------------------------------------------
  {
    name: "search_me",
    title: "Search Me",
    description:
      "Ranked full-text search across everything the user has written about themselves: role backgrounds, highlights, notes, projects and their profile. This is the FIRST tool to call when tailoring a resume or answering a question about their experience. Words match by stem ('managing' finds 'managed') and terms are ORed and ranked, so search the language of the POSTING, and search two or three times with different words before concluding they have no evidence for something. Returns an excerpt centred on each match with its id and kind, and `use`: only a hit whose use is \"evidence\" may reach a document. \"rules\" binds you without being quoted, \"caveats\" is their positioning note — context, never document material — and \"open\" is an unsettled fact, so ask. The profile's own background is not searched; get_me_snapshot with only \"profile\" has it. An empty query returns their roles, newest first.",
    inputSchema: object(
      {
        query: str("Keywords to search for, e.g. 'kubernetes cost savings' or 'led a team'"),
        limit: num("Max results (default 25)"),
      },
      ["query"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => me.searchMe(ctx.userId, required(args, "query"), n(args, "limit") ?? 25),
  },
  {
    name: "get_me_snapshot",
    title: "Get everything in Me",
    description:
      "EVERYTHING in Me at once: profile, every role with its full background, highlights, education, projects, skills, certifications and notes. Use it for complete context, e.g. a resume from scratch; it can be large, so prefer search_me for targeted lookups. `keywordPolicy` says how close to a posting's own words you may write — spelled out in `permits`, beside their transferable skills; read it before drafting. READ `writingRules`, `neverOnADocument` AND `notSettled` BEFORE YOU WRITE ANYTHING; the first and last cover the profile as well as every role. Obey `writingRules` silently. `neverOnADocument` is positioning notes and interview prep — context for you, never on a resume, a letter or anything an employer sees. Never state, round off or pick between `notSettled` facts; if a document needs one, ask. A role with `startUnconfirmed` or `endUnconfirmed` gets the year only for that date. Each role's `resumeEvidence` is the part a document may be written from.",
    inputSchema: object({
      include_background: bool(
        "Include the full long-form background text for each role (default true). Set false for a lighter payload.",
      ),
      only: {
        type: "string",
        enum: ["profile"],
        description:
          "\"profile\" for the identity block alone: name, headline, contact details, links, summary and their personal background. `hasPhoto` says whether a photo is set (the picture itself is never returned); `timeZone` is the calendar every date here is read against, or empty for the server's clock.",
      },
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      if (s(args, "only") === "profile") return withoutPhotoBytes(await me.getProfile(ctx.userId));
      const snapshot = await me.getMeSnapshot(ctx.userId);
      const profile = withoutPhotoBytes(snapshot.profile);
      if (b(args, "include_background") === false) {
        // The bulk goes; the rules and the caveats stay. They are a few lines
        // each, and they are the part a writer must not miss — a "lighter
        // payload" that quietly dropped the constraints would be the worst
        // possible thing to make cheaper.
        return {
          ...snapshot,
          profile: { ...profile, background: "[omitted]" },
          roles: snapshot.roles.map((r) => ({
            ...r,
            background: "[omitted]",
            resumeEvidence: "[omitted]",
          })),
        };
      }
      return { ...snapshot, profile };
    },
  },
  {
    name: "update_profile",
    title: "Update profile",
    description:
      "Update any subset of the user's profile fields. Only pass the fields you want to change; omitted fields are left untouched.",
    inputSchema: object({
      fullName: str("Full name"),
      headline: str("Professional headline, e.g. 'Senior Platform Engineer'"),
      email: str("Email address. Printed on every new resume and letter."),
      phone: str("Phone number. Printed on every new resume and letter — an empty string keeps it off paper, which is how somebody who wants no phone on their materials says so."),
      location: str("City, State/Country"),
      website: str("Personal website URL"),
      linkedin: str("LinkedIn URL"),
      github: str("GitHub URL"),
      twitter: str("X/Twitter URL"),
      summary: str("Career summary used as the default resume summary"),
      background: str(
        "Long-form personal background. REPLACES the existing text — read it first if you intend to add to it.",
      ),
      timeZone: str(
        "IANA time zone the user's dates are computed in, e.g. 'America/Chicago'. Everything dated follows it: what counts as today, when a follow-up is overdue, and the 9am a new follow-up is scheduled for. Pass an empty string to fall back to the server's own clock. Set this when they say where they are or that they have moved; an unrecognised name is refused rather than stored.",
      ),
      keywordPolicy: {
        type: "string",
        enum: [...KEYWORD_POLICIES],
        description:
          "How close to a posting's own words their documents may get. STRICT: never rephrase to match a posting. MATCH (the default): describe work they genuinely did in the posting's vocabulary — the same claim, spelled their way — which is what stops an applicant tracking system missing experience they really have. ADJACENT: also allows a tool they have NOT used to appear in a skills line marked comparable, but only where they have recorded a transferable that covers it. No level permits claiming work at an employer they did not do. Change this only when they ask; get_me_snapshot returns the current one with the rule spelled out.",
      },
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      // The zone is not a ProfilePatch key: that type is also what
      // import_resume accepts, and reading somebody's CV is no reason to move
      // their clock. Written through its own validating setter instead.
      const timeZone = s(args, "timeZone");
      if (timeZone !== undefined) await me.setTimeZone(ctx.userId, timeZone);
      return withoutPhotoBytes(
        await me.updateProfile(
          ctx.userId,
          defined({
            fullName: s(args, "fullName"),
            headline: s(args, "headline"),
            email: s(args, "email"),
            phone: s(args, "phone"),
            location: s(args, "location"),
            website: s(args, "website"),
            linkedin: s(args, "linkedin"),
            github: s(args, "github"),
            twitter: s(args, "twitter"),
            summary: s(args, "summary"),
            background: s(args, "background"),
            keywordPolicy: s(args, "keywordPolicy") as KeywordPolicy | undefined,
          }),
        ),
      );
    },
  },
  {
    name: "set_profile_photo",
    title: "Set the profile photo",
    description:
      "Give the user a headshot, or remove theirs. One picture serves the whole app: their avatar, and the photo on every resume whose design shows one — replacing it here updates them all. Pass `url` for an https JPEG, PNG or WebP already on the web (a GitHub avatar, a personal site), `data_uri` when you hold the bytes, or remove: true to clear it. Over 400KB is refused, so downscale first; a 512px original is plenty. Turning the photo ON for a resume is a separate step: update_resume with showPhoto: true. Never invent a picture of somebody — use only a URL or file the user gave you.",
    inputSchema: object({
      url: str("https link to an image to fetch and store"),
      data_uri: str("The image inline, e.g. 'data:image/jpeg;base64,…'"),
      remove: bool("Remove the existing photo"),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) => {
      if (b(args, "remove")) {
        await me.setProfilePhoto(ctx.userId, "");
        return { photo: false, message: "Photo removed. Resumes that showed it now render without one." };
      }
      const source = s(args, "data_uri")?.trim() || s(args, "url")?.trim() || "";
      if (!source) throw new Error("Pass a url, a data_uri, or remove: true.");
      const result = await me.setProfilePhoto(ctx.userId, source);
      return {
        ...result,
        message:
          "Saved. It shows in the app straight away; a resume also needs showPhoto: true, and the Harvard template never renders one.",
      };
    },
  },
  {
    name: "list_roles",
    title: "List roles",
    description:
      "List every job/role in the knowledge base with dates, how many highlights each has, and a short excerpt of its background — evidence only, never a rule or a caveat. Does not include the full background: use get_role for that, and for the rules, caveats and open questions it carries. Capped, and the result says so when it was cut off.",
    inputSchema: object({ limit: limitArg(100) }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    // The background stays out, as the description promises: five long roles
    // were arriving whole on every "list my jobs", which is the call made most
    // often and needs it least. The excerpt says what each role is about, so
    // the next call — get_role on the one that matters — is obvious.
    handler: async (args, ctx) => {
      const rows = await me.listRoles(ctx.userId);
      return capped(
        rows.map(({ background, userId: _owner, ...role }) => ({
          ...role,
          excerpt: backgroundExcerpt(background, 200),
        })),
        n(args, "limit"),
        100,
      );
    },
  },
  {
    name: "get_role",
    title: "Get a role",
    description:
      "One role in full: its background, that background READ INTO SECTIONS, and all its highlights. `resumeEvidence` is the only part a document may be written from. `rules` bind how this job is described — follow them silently, never quote them. `caveats` are their positioning notes and interview prep: context for YOU, never on a resume, a letter or anything an employer sees. `open` is what they have not settled, an unconfirmed start or end month included — never state, round or choose a version; ask, and settle it with resolve_open_question. When `startUnconfirmed` or `endUnconfirmed` is true, write the year only. `notes` filed against this job are evidence too. `sections` is the whole thing in order, each tagged by kind. Markers are a heading ('## Rules', '## Caveats', '## Open questions', or a shouted suffix like '— INTERVIEW ONLY') or a shouted label opening a line ('NAMING RULE:', '⚠️ OPEN:'); lowercase prose never marks anything, and an unmarked background is all evidence.",
    inputSchema: object({ id: str("Role id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const role = await me.getRole(ctx.userId, required(args, "id"));
      if (!role) throw new Error(`No role with id ${required(args, "id")}`);
      return role;
    },
  },
  {
    name: "list_open_questions",
    title: "What they have not settled yet",
    description:
      "Every fact they have marked as not settled: questions in a role's background ('⚠️ OPEN:', '## Open questions'), in their profile's personal background (roleId null), and start or end months marked unconfirmed. Each comes with roleId, role, the question as written and its `kind` — background, start_date or end_date. Reach for it when they ask what is left to tidy in Me, before writing a dated or numbered document, or on 'let's go through my open questions'. Ask them ONE AT A TIME and never answer one yourself. When they answer, call resolve_open_question with the question exactly as it came back. These are already kept off documents, so an empty list means nothing is pending. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => me.listOpenQuestions(ctx.userId),
  },
  {
    name: "resolve_open_question",
    title: "Settle an open question",
    description:
      "Record their answer to one open question and take the question away. Call it only with an answer THEY gave you in this conversation — never a guess, a rounding or the likelier of two figures. Pass the question exactly as list_open_questions returned it. A marked line inside the background ('- ⚠️ OPEN: $40K or $45K?') is replaced in place by the answer; a line under '## Open questions' is removed and the answer filed as evidence, under `heading` if you give one. An unconfirmed start or end month is settled by passing the month as YYYY-MM (or YYYY if only the year is certain), or no answer to confirm the date on file. Leaving out `answer` on a background question DROPS it. A role's old background stays in its version history, so restore_revision can bring it back; the profile keeps no versions, so a profile question settled stays settled. Returns what was resolved.",
    inputSchema: object(
      {
        role_id: str("The role the question belongs to. Omit for a question in the profile's personal background."),
        question: str("The question exactly as list_open_questions returned it."),
        answer: str(
          "Their answer, as a fact written the way it should appear in the background. For a date question, YYYY-MM or YYYY. Omit to drop a question that no longer matters, or to confirm a date as it stands.",
        ),
        heading: str(
          "Only for a question from an 'Open questions' section: the evidence heading to file the answer under, e.g. 'Scope'. Merges into an existing section of that name.",
        ),
      },
      ["question"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      me.resolveOpenQuestion(
        ctx.userId,
        s(args, "role_id") ?? null,
        required(args, "question"),
        s(args, "answer"),
        s(args, "heading"),
        mcpAuthor(ctx, "resolve_open_question"),
      ),
  },
  {
    name: "create_role",
    title: "Create a role",
    description:
      "Add a job to the knowledge base. Put every raw detail you were given into background — it is unlimited and is the raw material for future resumes.",
    inputSchema: object(
      {
        company: str("Company name"),
        title: str("Job title"),
        employmentType: str("Full-time, Contract, Internship, Freelance…"),
        location: str("City, State or 'Remote'"),
        startDate: str("Start date as YYYY-MM"),
        endDate: str("End date as YYYY-MM. Leave empty if current."),
        isCurrent: bool("True if this is their current job"),
        summary: str("One or two sentences describing the scope of the role"),
        background: str(
          "THE BACKGROUND. Everything raw: projects, metrics, technologies, stories, praise, failures, org context. Markdown welcome. No length limit.",
        ),
        tags: strArray("Freeform tags, e.g. ['fintech','ic','python']"),
        startUnconfirmed: bool(
          "True when the start MONTH is a guess. A writer then prints the year only, or asks. Use it rather than writing the doubt into the background.",
        ),
        endUnconfirmed: bool("True when the end MONTH is a guess. Same rule."),
      },
      ["company", "title"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      me.createRole(ctx.userId, {
        company: required(args, "company"),
        title: required(args, "title"),
        ...defined({
          employmentType: s(args, "employmentType"),
          location: s(args, "location"),
          startDate: s(args, "startDate"),
          endDate: s(args, "endDate"),
          isCurrent: b(args, "isCurrent"),
          summary: s(args, "summary"),
          background: s(args, "background"),
          tags: a(args, "tags"),
          startUnconfirmed: b(args, "startUnconfirmed"),
          endUnconfirmed: b(args, "endUnconfirmed"),
        }),
      }),
  },
  {
    name: "update_role",
    title: "Update a role",
    description:
      "Update fields on an existing role. WARNING: passing background REPLACES the whole thing — use append_role_background to add to it safely. When they say a start or end month is a guess, set startUnconfirmed / endUnconfirmed rather than noting it in the background: the flag keeps the month off every resume (add_role_to_resume prints the year only) and puts the date on their list of things to settle.",
    inputSchema: object(
      {
        id: str("Role id"),
        company: str("Company name"),
        title: str("Job title"),
        employmentType: str("Employment type"),
        location: str("Location"),
        startDate: str("Start date as YYYY-MM"),
        endDate: str("End date as YYYY-MM"),
        isCurrent: bool("Is this the current job"),
        summary: str("Scope summary"),
        background: str("Replaces the entire background"),
        tags: strArray("Tags"),
        startUnconfirmed: bool(
          "True when the start MONTH is a guess. A writer then prints the year only, or asks. Use it rather than writing the doubt into the background.",
        ),
        endUnconfirmed: bool("True when the end MONTH is a guess. Same rule."),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    versioned: true,
    handler: async (args, ctx) =>
      me.updateRole(ctx.userId, 
        required(args, "id"),
        defined({
          company: s(args, "company"),
          title: s(args, "title"),
          employmentType: s(args, "employmentType"),
          location: s(args, "location"),
          startDate: s(args, "startDate"),
          endDate: s(args, "endDate"),
          isCurrent: b(args, "isCurrent"),
          summary: s(args, "summary"),
          background: s(args, "background"),
          tags: a(args, "tags"),
          startUnconfirmed: b(args, "startUnconfirmed"),
          endUnconfirmed: b(args, "endUnconfirmed"),
        }),
        mcpAuthor(ctx, "update_role"),
      ),
  },
  {
    name: "posting_keywords",
    title: "Where a posting's keywords land",
    description:
      "Read ONE posting on the board and say, term by term, what it lands on: `onFile` with the record that evidences it, `covered` where only a recorded transferable reaches it, `missing` where there is neither. CALL THIS BEFORE TAILORING ANYTHING. Applicant tracking systems screen on exact tokens, so an `onFile` term your draft spells differently is a screen lost over vocabulary; a `covered` term may go in a skills line as comparable if their keyword policy allows. Never move a `covered` term into a bullet about a job, and never invent evidence for a `missing` one — report it as a gap. Terms are ordered by how often the posting repeats them. skills_gap asks the same across every posting, for deciding what to learn; this one decides what goes on the page.",
    inputSchema: object({ application_id: str("Application id, from list_applications") }, ["application_id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      transferables.postingKeywords(ctx.userId, required(args, "application_id")),
  },
  {
    name: "append_role_background",
    title: "Append to a role's background",
    description:
      "Safely ADD text to the end of a role's background without touching what is there. The right tool when the user tells you something new about a job on file — and whenever they mention something that went well at their current job (list_roles marks it): a project shipped, a number moved, somebody thanked them. Offer to file it even unasked; Me stops growing the day a search ends. Write it in their words with whatever number they gave, under a month heading like \"October 2026\"; do NOT polish it into a resume bullet — mine_role_background does that later, with them. A `heading` the background already has merges into that section. Three headings are reserved and change what the text MEANS: \"Rules\" for a binding instruction on how this job may be described, \"Caveats\" for positioning and interview prep that must never reach a document, and \"Open questions\" for a fact they are not sure of yet. Use them when they are telling you a CONSTRAINT, a WORRY or an UNCERTAINTY rather than something they did; everything else is evidence. Once an open question is settled, resolve_open_question files the answer — never rewrite the whole background for it.",
    inputSchema: object(
      {
        id: str("Role id"),
        text: str("The new material to append. Markdown welcome."),
        heading: str(
          "Optional markdown H2 heading to file it under, e.g. 'Q3 platform migration'. Merges into an existing section of the same name. 'Rules', 'Caveats' and 'Open questions' are reserved — see the description.",
        ),
      },
      ["id", "text"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    versioned: true,
    handler: async (args, ctx) =>
      me.appendToRoleBackground(
        ctx.userId,
        required(args, "id"),
        required(args, "text"),
        s(args, "heading"),
        mcpAuthor(ctx, "append_role_background"),
      ),
  },
  {
    name: "delete_role",
    title: "Delete a role",
    description:
      "Destroy a job and everything hanging off it: its background, every highlight drawn from it, and the evidence behind any resume bullet that came from there. There is no archive for a role and no undo — this is not archive_records, which files a company, a person or an application away. Resumes already written keep their text; what they lose is the material that proves it, so trace_resume_evidence will start reporting those bullets as unsupported. Almost nobody means this: a role kept out of a document is a resume edit, not a deletion. Read it back with get_role and get a plain yes first.",
    inputSchema: object({ id: str("Role id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => me.deleteRole(ctx.userId, required(args, "id")),
  },

  // -------------------------------------------------------------------------
  // ME — highlights
  // -------------------------------------------------------------------------
  {
    name: "list_highlights",
    title: "List highlights",
    description:
      "Reusable, polished achievement bullets, strongest first. These are the distilled lines you pull from when assembling a resume. Each carries `usedIn`: the resumes that have a bullet saying the same thing (matched by meaning, the way trace_resume_evidence matches, not by exact text), most recently edited first. A strong highlight with an empty `usedIn` is one they have never put in front of anybody — worth offering when tailoring. Capped, and the result says so when it was cut off — pass roleId to narrow it to one job.",
    inputSchema: object({ roleId: str("Only return highlights for this role id"), limit: limitArg(200) }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const [rows, usage] = await Promise.all([
        me.listHighlights(ctx.userId, s(args, "roleId")),
        me.highlightUsage(ctx.userId),
      ]);
      return capped(
        rows.map((row) => ({ ...row, usedIn: usage.get(row.id) ?? [] })),
        n(args, "limit"),
        200,
      );
    },
  },
  {
    name: "create_highlights",
    title: "Create highlights",
    description:
      "Distil raw background material into one or more reusable achievement bullets. Write them in resume voice: strong verb, specific scope, quantified outcome. Create several at once.",
    inputSchema: object(
      {
        highlights: {
          type: "array",
          description: "The highlights to create",
          items: object(
            {
              roleId: str("Role this belongs to (omit for a standalone highlight)"),
              text: str("The bullet, in resume voice"),
              impact: str("The quantified outcome if it is not already inside `text`"),
              tags: strArray("Tags for retrieval, e.g. ['leadership','cost']"),
              strength: num("1-5 how strong this bullet is. Default 3."),
            },
            ["text"],
          ),
        },
      },
      ["highlights"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const items = Array.isArray(args.highlights) ? (args.highlights as Json[]) : [];
      return me.createHighlights(ctx.userId, 
        items.map((item) => ({
          roleId: s(item, "roleId") ?? null,
          text: required(item, "text"),
          impact: s(item, "impact"),
          tags: a(item, "tags"),
          strength: n(item, "strength"),
        })),
      );
    },
  },
  {
    name: "update_highlight",
    title: "Update a highlight",
    description:
      "Rewrite one achievement bullet, or take it out of circulation with archived: true — which keeps it on file and stops it being offered for new resumes, and is what to reach for when somebody has outgrown a bullet rather than disowned it. Passing `text` REPLACES the bullet outright, so read it with list_highlights first if you are sharpening the wording rather than writing a new claim. Never strengthen a number or a scope the user did not give you. Resumes already written are not rewritten by this: they hold their own copy of the text.",
    inputSchema: object(
      {
        id: str("Highlight id"),
        text: str("New bullet text"),
        impact: str("New impact"),
        tags: strArray("New tags"),
        strength: num("1-5"),
        archived: bool("Archive it instead of deleting"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      me.updateHighlight(ctx.userId, 
        required(args, "id"),
        defined({
          text: s(args, "text"),
          impact: s(args, "impact"),
          tags: a(args, "tags"),
          strength: n(args, "strength"),
          archived: b(args, "archived"),
        }),
      ),
  },
  {
    name: "delete_highlight",
    title: "Delete a highlight",
    description:
      "Destroy one achievement bullet. Permanent, with no archive behind it. Prefer update_highlight with archived: true when the bullet is simply out of date — that keeps the claim on file and out of new documents, which is what somebody usually means. Delete when the claim itself was wrong.",
    inputSchema: object({ id: str("Highlight id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => me.deleteHighlight(ctx.userId, required(args, "id")),
  },

  // -------------------------------------------------------------------------
  // ME — notes and extras
  // -------------------------------------------------------------------------
  {
    name: "list_notes",
    title: "List notes",
    description:
      "Free-floating notes not tied to one job: STAR stories, interview prep, references, compensation history. Capped, and the result says when it was cut — except standing rules (kind GUARDRAIL), which are never cut and come first. Each standing rule carries `inBriefing`: whether it fits in the briefing every client reads on connect. A false one still binds but only reaches clients that come here for it, so offer to shorten it or split it into several short rules — create_note with kind GUARDRAIL for each, then update_note to make the original an ordinary NOTE. A note with a `role` is filed against that job. A NOTE whose title reads like a rule ('Guardrails', 'never do X') is NOT a standing rule and reaches nobody unasked — say so and offer update_note with kind GUARDRAIL. For something specific, search_me is better.",
    inputSchema: object({
      limit: limitArg(100),
      role_id: str("Only the notes filed against this role"),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      // Notes sort NOTE before GUARDRAIL, because that is the enum's order and
      // Postgres sorts an enum by declaration. So a plain cap took the first
      // hundred ordinary notes and dropped every standing rule — the one thing
      // on this server that must never go missing, cut by the very feature
      // added to stop things being cut silently.
      //
      // Guardrails are few by design and they come first now, whatever the
      // limit. The cap applies to what is left.
      const [notes, fit] = await Promise.all([
        me.listNotes(ctx.userId, defined({ roleId: s(args, "role_id") })),
        standingRulesFit(ctx.userId, ctx.user),
      ]);
      const inBriefing = new Set(fit.rules.filter((rule) => rule.inBriefing).map((rule) => rule.id));
      // Each standing rule says whether it is actually in the briefing every
      // client reads on connect. One that is not still counts — the briefing
      // tells clients to come here for it — but it is worth shortening.
      const rules = notes
        .filter((note) => note.kind === "GUARDRAIL")
        .map((note) => ({ ...note, inBriefing: inBriefing.has(note.id) }));
      const rest = notes.filter((note) => note.kind !== "GUARDRAIL");
      const max = Math.min(Math.max(Math.trunc(n(args, "limit") ?? 100), 1), LIST_CEILING);
      const room = Math.max(max - rules.length, 0);
      const rows = [...rules, ...rest.slice(0, room)];
      // Counted over everything returned and everything on file, not over the
      // slice that was capped — the rules are part of the answer.
      return rows.length < notes.length
        ? withNotice(rows, truncationNotice(rows.length, notes.length))
        : rows;
    },
  },
  {
    name: "create_note",
    title: "Create a note",
    description:
      "Save a free-floating note. Use this for raw material that does not belong to one specific job. Set kind: GUARDRAIL to make it a standing rule instead — see the kind field.",
    inputSchema: object(
      {
        title: str("Short title"),
        body: str("The note body. Markdown welcome, no length limit."),
        tags: strArray("Tags"),
        pinned: bool("Pin to the top of the notes list"),
        role_id: str(
          "The role this note is about — a STAR story from that job, say. It then appears on the role and in get_role. Pass an empty string to detach it. Most notes belong to no one job; leave it out for those.",
        ),
        kind: {
          type: "string",
          enum: ["NOTE", "GUARDRAIL"],
          description:
            "GUARDRAIL makes this a standing rule: it is carried in the briefing every AI client receives on connect, so it constrains work before any tool is called. Use it for things that must never be got wrong — how they may and may not be described, numbers that are unsettled and must not be cited, credit that must not be overstated. Everything else is a NOTE (the default), which is only found by searching.",
        },
      },
      ["title"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      me.createNote(ctx.userId, {
        title: required(args, "title"),
        ...defined({
          body: s(args, "body"),
          tags: a(args, "tags"),
          pinned: b(args, "pinned"),
          kind: noteKind(args),
          roleId: s(args, "role_id"),
        }),
      }),
  },
  {
    name: "update_note",
    title: "Update a note",
    description:
      "Edit an existing note. Passing `body` replaces the whole body. Promote a note to a standing rule, or demote one, with `kind`.",
    inputSchema: object(
      {
        id: str("Note id"),
        title: str("New title"),
        body: str("New body (replaces existing)"),
        tags: strArray("New tags"),
        pinned: bool("Pinned state"),
        role_id: str(
          "The role this note is about — a STAR story from that job, say. It then appears on the role and in get_role. Pass an empty string to detach it. Most notes belong to no one job; leave it out for those.",
        ),
        kind: {
          type: "string",
          enum: ["NOTE", "GUARDRAIL"],
          description:
            "GUARDRAIL makes this a standing rule: it is carried in the briefing every AI client receives on connect, so it constrains work before any tool is called. Use it for things that must never be got wrong — how they may and may not be described, numbers that are unsettled and must not be cited, credit that must not be overstated. Everything else is a NOTE (the default), which is only found by searching.",
        },
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      me.updateNote(ctx.userId, 
        required(args, "id"),
        defined({
          title: s(args, "title"),
          body: s(args, "body"),
          tags: a(args, "tags"),
          pinned: b(args, "pinned"),
          kind: noteKind(args),
          roleId: s(args, "role_id"),
        }),
      ),
  },
  {
    name: "delete_note",
    title: "Delete a note",
    description:
      "Permanently delete a note. This is the only way to remove one — a note has no archived state, unlike a highlight. It matters most for standing rules: a GUARDRAIL is carried in the briefing every AI client receives on connect, so a rule that no longer holds keeps shaping work until it is deleted. Read it back with list_notes and say which one you are about to remove, because the text is not recoverable.",
    inputSchema: object({ id: str("Note id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => me.deleteNote(ctx.userId, required(args, "id")),
  },
  {
    name: "list_extras",
    title: "List education / projects / skills / certifications / transferables",
    description:
      "Read one of the supporting knowledge-base collections: education history, side projects, skill groups, certifications, or transferables. A TRANSFERABLE is a tool they have ACTUALLY used (`have`) and the tools it reaches (`covers`, spelled the way a posting spells them), with their own note on why — the answer to 'the posting wants HubSpot and they have only ever run Salesforce'. Read them before writing for a posting that names a tool you cannot find in Me: a recorded transfer lets that tool appear in a SKILLS line marked comparable, under their keyword policy, and never in a bullet about a job. A transfer that is not on file does not exist — do not infer one.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: ["education", "projects", "skills", "certifications", "transferables"],
          description: "Which collection to read",
        },
      },
      ["kind"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      switch (required(args, "kind")) {
        case "education":
          return me.listEducation(ctx.userId);
        case "projects":
          return me.listProjects(ctx.userId);
        case "skills":
          return me.listSkillGroups(ctx.userId);
        case "certifications":
          return me.listCertifications(ctx.userId);
        case "transferables":
          return transferables.listTransferables(ctx.userId);
        default:
          throw new Error("kind must be education | projects | skills | certifications | transferables");
      }
    },
  },
  {
    name: "create_extra",
    title: "Add education / project / skill group / certification / transferable",
    description:
      "Add an item to one of the supporting collections. Only the fields relevant to `kind` are read — see each field's description for which kind it belongs to. For a transferable, reach for it when they say a version of 'I have only used X but Y is the same thing'. Ask them for the list rather than guessing it: the point of storing it is that THEY stand behind it in a room, and a bridge you invented is one they cannot defend.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: ["education", "projects", "skills", "certifications", "transferables"],
          description: "Which collection to add to",
        },
        school: str("[education] School name"),
        degree: str("[education] e.g. 'B.S.'"),
        field: str("[education] e.g. 'Computer Science'"),
        gpa: str("[education] GPA"),
        details: str("[education] Honours, coursework, activities"),
        name: str("[projects] project name · [skills] group name · [certifications] cert name"),
        role: str("[projects] Your role on the project"),
        url: str("[projects | certifications] Link"),
        description: str("[projects] One-line description"),
        background: str("[projects] Long-form raw detail about the project"),
        skills: strArray("[skills] The skills in this group, e.g. ['Python','Go','Rust']"),
        issuer: str("[certifications] Issuing body"),
        date: str("[certifications] Date earned"),
        location: str("[education] Location"),
        startDate: str("[education | projects] YYYY-MM"),
        endDate: str("[education | projects] YYYY-MM"),
        tags: strArray("[projects] Tags"),
        have: str("[transferables] What they have actually used, e.g. 'Salesforce'"),
        covers: strArray("[transferables] What it transfers to, as a posting names them, e.g. ['HubSpot', 'Pipedrive']"),
        note: str("[transferables] Their own words on why it transfers and how fast they would be productive"),
      },
      ["kind"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      switch (required(args, "kind")) {
        case "education":
          return me.createEducation(ctx.userId, {
            school: required(args, "school"),
            ...defined({
              degree: s(args, "degree"),
              field: s(args, "field"),
              location: s(args, "location"),
              startDate: s(args, "startDate"),
              endDate: s(args, "endDate"),
              gpa: s(args, "gpa"),
              details: s(args, "details"),
            }),
          });
        case "projects":
          return me.createProject(ctx.userId, {
            name: required(args, "name"),
            ...defined({
              role: s(args, "role"),
              url: s(args, "url"),
              description: s(args, "description"),
              background: s(args, "background"),
              tags: a(args, "tags"),
              startDate: s(args, "startDate"),
              endDate: s(args, "endDate"),
            }),
          });
        case "skills":
          return me.createSkillGroup(ctx.userId, {
            name: required(args, "name"),
            skills: a(args, "skills") ?? [],
          });
        case "certifications":
          return me.createCertification(ctx.userId, {
            name: required(args, "name"),
            ...defined({ issuer: s(args, "issuer"), date: s(args, "date"), url: s(args, "url") }),
          });
        case "transferables":
          return transferables.createTransferable(ctx.userId, {
            have: required(args, "have"),
            covers: a(args, "covers"),
            note: s(args, "note"),
          });
        default:
          throw new Error("kind must be education | projects | skills | certifications | transferables");
      }
    },
  },
  {
    name: "update_extra",
    title: "Update an education / project / skill group / certification / transferable",
    description:
      "Change fields on an item in one of the supporting collections. Reach for this instead of deleting and re-creating — that would hand the item a new id and break anything referring to it. Only the fields you pass are changed; everything you leave out keeps its current value, so you do not need to read the item first. Fields not relevant to `kind` are ignored.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: ["education", "projects", "skills", "certifications", "transferables"],
          description: "Which collection the item is in",
        },
        id: str("Id of the item to change"),
        school: str("[education] School name"),
        degree: str("[education] e.g. 'B.S.'"),
        field: str("[education] e.g. 'Computer Science'"),
        gpa: str("[education] GPA"),
        details: str("[education] Honours, coursework, activities"),
        name: str("[projects] project name · [skills] group name · [certifications] cert name"),
        role: str("[projects] Your role on the project"),
        url: str("[projects | certifications] Link"),
        description: str("[projects] One-line description"),
        background: str("[projects] Long-form raw detail about the project"),
        skills: strArray("[skills] REPLACES the whole list, e.g. ['Python','Go','Rust']"),
        issuer: str("[certifications] Issuing body"),
        date: str("[certifications] Date earned"),
        location: str("[education] Location"),
        startDate: str("[education | projects] YYYY-MM"),
        endDate: str("[education | projects] YYYY-MM"),
        tags: strArray("[projects] REPLACES the whole list"),
        have: str("[transferables] What they have actually used"),
        covers: strArray("[transferables] The COMPLETE list of what it transfers to. REPLACES what is stored — read it first and write it back entire, or adding one silently drops the rest."),
        note: str("[transferables] Their own words on why it transfers"),
      },
      ["kind", "id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const id = required(args, "id");
      switch (required(args, "kind")) {
        case "education":
          return me.updateEducation(ctx.userId, id, 
            defined({
              school: s(args, "school"),
              degree: s(args, "degree"),
              field: s(args, "field"),
              location: s(args, "location"),
              startDate: s(args, "startDate"),
              endDate: s(args, "endDate"),
              gpa: s(args, "gpa"),
              details: s(args, "details"),
            }),
          );
        case "projects":
          return me.updateProject(ctx.userId, id, 
            defined({
              name: s(args, "name"),
              role: s(args, "role"),
              url: s(args, "url"),
              description: s(args, "description"),
              background: s(args, "background"),
              tags: a(args, "tags"),
              startDate: s(args, "startDate"),
              endDate: s(args, "endDate"),
            }),
          );
        case "skills":
          return me.updateSkillGroup(ctx.userId, id, 
            defined({ name: s(args, "name"), skills: a(args, "skills") }),
          );
        case "certifications":
          return me.updateCertification(ctx.userId, id, 
            defined({
              name: s(args, "name"),
              issuer: s(args, "issuer"),
              date: s(args, "date"),
              url: s(args, "url"),
            }),
          );
        case "transferables":
          return transferables.updateTransferable(
            ctx.userId,
            id,
            defined({ have: s(args, "have"), covers: a(args, "covers"), note: s(args, "note") }),
          );
        default:
          throw new Error("kind must be education | projects | skills | certifications | transferables");
      }
    },
  },
  {
    name: "delete_extra",
    title: "Delete an education / project / skill group / certification / transferable",
    description:
      "Destroy one education entry, project, skill group, certification or transferable. A deleted transferable only stops the bridge being offered on the next document; nothing already written changes. Permanent, with no archive behind it, and it takes the `kind` as well as the id because ids are only unique within a collection. Reach for update_extra instead when the item is merely wrong: deleting and re-creating mints a new id and breaks anything pointing at the old one. Resumes already written keep their copy of the text.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: ["education", "projects", "skills", "certifications", "transferables"],
          description: "Which collection the item is in",
        },
        id: str("Item id"),
      },
      ["kind", "id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const id = required(args, "id");
      switch (required(args, "kind")) {
        case "education":
          return me.deleteEducation(ctx.userId, id);
        case "projects":
          return me.deleteProject(ctx.userId, id);
        case "skills":
          return me.deleteSkillGroup(ctx.userId, id);
        case "certifications":
          return me.deleteCertification(ctx.userId, id);
        case "transferables":
          await transferables.deleteTransferable(ctx.userId, id);
          return { deleted: id };
        default:
          throw new Error("kind must be education | projects | skills | certifications | transferables");
      }
    },
  },
  {
    name: "import_resume",
    title: "Import a resume into Me",
    description:
      "Turn an existing resume, LinkedIn profile text or any pasted career history into a filled-in Me in ONE call. Reach for this first when the workspace is empty and the user has a document — offer it before asking them to talk through their life. A LinkedIn data-export zip goes to import_linkedin_archive. You do the reading: parse the text into profile, roles with their bullets, education, projects, skills and certifications. Copy what the document says and NEVER invent, upgrade or round anything — no employer, title, date or metric it does not state; a field it is silent on stays absent. Include startDate on every role: two stints at one company import as two only when their dates differ. ADDITIVE and safe to repeat: nothing is overwritten or removed, profile fields fill only where empty, a role already on file (same company, title and start) gains only the bullets it lacks, and a matching education entry, project or certification is skipped. Bullets become highlights. Returns what was created, merged into (with bullets gained) and skipped — report it back. When the workspace is NOT empty, call with dry_run true first: the same report, nothing written. Offer create_base_resume: true, since a resume is usually why they pasted one; it reuses one named 'Base resume'.",
    inputSchema: object(
      {
        profile: {
          type: "object",
          description:
            "Contact and identity facts from the document. Only fills fields that are currently empty.",
          properties: {
            fullName: str("Their name as the document states it"),
            headline: str("Professional headline / current title line"),
            email: str("Email address"),
            phone: str("Phone number"),
            location: str("City / region"),
            website: str("Personal site URL"),
            linkedin: str("LinkedIn URL"),
            github: str("GitHub URL"),
            summary: str("The document's own summary or objective paragraph, verbatim or lightly cleaned"),
          },
          additionalProperties: false,
        },
        roles: {
          type: "array",
          description: "One entry per job in the document, newest first.",
          items: object(
            {
              company: str("Employer name"),
              title: str("Job title"),
              employmentType: str("Full-time (default) | Part-time | Contract | Freelance | Internship"),
              location: str("Where, if stated"),
              startDate: str(
                "YYYY-MM if stated. Part of the role's identity — two stints at the same company+title are told apart by it, so include it whenever the document has it.",
              ),
              endDate: str("YYYY-MM, empty if current"),
              isCurrent: bool("True when the document marks it as current"),
              summary: str("The role's one-line scope, if the document has one"),
              background: str(
                "Raw text about this role beyond the bullets — the section as pasted is fine. Omit to auto-fill from the bullets.",
              ),
              bullets: {
                type: "array",
                description: "The role's bullet points, one per bullet, wording kept.",
                items: object(
                  {
                    text: str("The bullet as the document states it"),
                    impact: str("Its quantified outcome, only if not already inside text"),
                    tags: strArray("Tags for retrieval, e.g. ['leadership','cost']"),
                    strength: num("1-5 how strong the bullet reads. Default 3."),
                  },
                  ["text"],
                ),
              },
            },
            ["company", "title"],
          ),
        },
        education: {
          type: "array",
          description: "Education entries.",
          items: object(
            {
              school: str("Institution"),
              degree: str("Degree, e.g. 'BSc'"),
              field: str("Field of study"),
              location: str("Where, if stated"),
              startDate: str("YYYY-MM if stated"),
              endDate: str("YYYY-MM if stated"),
              gpa: str("GPA / grade, only if stated"),
              details: str("Honours, coursework, anything else the entry lists"),
            },
            ["school"],
          ),
        },
        projects: {
          type: "array",
          description: "Side or portfolio projects the document lists.",
          items: object(
            {
              name: str("Project name"),
              role: str("Their part in it"),
              url: str("Link, if stated"),
              description: str("What it is, from the document"),
              startDate: str("YYYY-MM if stated"),
              endDate: str("YYYY-MM if stated"),
              tags: strArray("Tags for retrieval"),
            },
            ["name"],
          ),
        },
        skillGroups: {
          type: "array",
          description:
            "Skills grouped the way the document groups them, e.g. {name: 'Languages', skills: ['Go','TypeScript']}. Use one group named 'Skills' when it is a flat list.",
          items: object(
            { name: str("Group label"), skills: strArray("The skills in it") },
            ["name"],
          ),
        },
        certifications: {
          type: "array",
          description: "Certifications and licences.",
          items: object(
            {
              name: str("Certification name"),
              issuer: str("Who issued it"),
              date: str("When, as the document states it"),
              url: str("Verification link, if stated"),
            },
            ["name"],
          ),
        },
        on_existing: str(
          "What to do with a role already on file: 'merge' (default) adds the bullets it does not have and appends the new wording to its background; 'skip' leaves it completely untouched, which is what this tool used to do. Merging never edits or removes anything, so prefer it.",
        ),
        create_base_resume: bool(
          "Also build a first draft resume from what was imported, named 'Base resume'. Offer this — it is usually why they pasted a resume.",
        ),
        dry_run: bool(
          "Report exactly what this WOULD change and write nothing: the roles it would create, the roles already on file it would add bullets to and how many each gains, what it would skip, and which profile fields would fill. It runs the real import and rolls it back, so the answer cannot drift. Use it first whenever the workspace is NOT empty — a second resume from someone with history is where 'what will this do to what I have?' is a real question — then call again without it to go ahead.",
        ),
      },
      [],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      if (b(args, "dry_run")) {
        return me.previewResumeImport(ctx.userId, importPayloadFrom(args), onExistingFrom(args));
      }
      const result = await me.importResume(
        ctx.userId,
        importPayloadFrom(args),
        onExistingFrom(args),
      );

      if (b(args, "create_base_resume")) {
        // Reuse before create lives in the data layer, because the import
        // dialog does the same thing at the end of the same flow.
        return { ...result, baseResume: await resumes.ensureBaseResume(ctx.userId) };
      }
      return result;
    },
  },

  {
    name: "import_linkedin_archive",
    title: "Import a LinkedIn data export",
    description:
      "Fill in Me from LinkedIn's own data export — the zip from Settings → Data privacy → Get a copy of your data. Reach for this the moment someone says they have one: it needs no interpreting, so it is more accurate than pasting a resume or a profile page. Unzip it yourself and pass each file in `files` with its text EXACTLY as the file has it — do not reformat, summarise or tidy the CSV. It reads Profile, Positions, Education, Skills, Languages, Certifications, Projects and Email Addresses; pass whichever exist, and it names any file it could not use. `jobs` and `connections` are opt-in — offer them, do not assume them. ADDITIVE and safe to re-run, on the same rules as import_resume: nothing is overwritten, and nothing already on file is created twice. ROLES CARRY NO BULLETS: LinkedIn's description is prose, and it lands whole in the role's background rather than being split into achievement lines they never wrote. On a workspace that is not empty, run dry_run true first and read the report back. Returns a ROW COUNT per file — zero on a file that should have rows means LinkedIn renamed a column, so say so — plus what was ignored, created, merged into and skipped. Report that; do not claim success blindly. Never add an employer, title, date or metric that is not in the file.",
    inputSchema: object(
      {
        files: {
          type: "array",
          description:
            "The archive's CSV files. Names are matched on the basename, so 'Jobs/Job Applications.csv' is fine.",
          items: object(
            {
              name: str("The file's name, e.g. 'Positions.csv'"),
              text: str("The file's contents, verbatim, including the header row"),
            },
            ["name", "text"],
          ),
        },
        jobs: bool(
          "Also create applications from Jobs/Job Applications.csv, at APPLIED with the date LinkedIn recorded. Default false — offer it, do not assume it",
        ),
        connections: bool(
          "Also file people from Connections.csv whose Company is already a company on file. Default false. Capped at 200",
        ),
        on_existing: str(
          "'merge' (default) or 'skip', matching import_resume. Merge adds what a role does not already have; skip drops the whole entry",
        ),
        dry_run: bool(
          "Do every lookup and no writes, and report the same numbers. Do this first whenever the workspace is not empty",
        ),
      },
      ["files"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      linkedin.importLinkedInArchive(ctx.userId, archiveFilesFrom(args), {
        ...onExistingFrom(args),
        jobs: b(args, "jobs") ?? false,
        connections: b(args, "connections") ?? false,
        dryRun: b(args, "dry_run") ?? false,
      }),
  },

  // -------------------------------------------------------------------------
  // RESUMES
  // -------------------------------------------------------------------------
  {
    name: "get_resume_format",
    title: "Get the resume document format",
    description:
      "Returns the exact JSON shape of a resume document plus the available templates, fonts and writing guidance. Call this once before your first create_resume or update_resume so the document you build validates.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => ({
      documentShape: RESUME_DOC_SHAPE,
      defaultTemplate: DEFAULT_TEMPLATE,
      // From the one catalogue, so the picker in the app and the list an
      // assistant is handed cannot say different things.
      templates: RESUME_TEMPLATES.map((template) => ({
        key: template.key,
        description: template.description,
      })),
      fonts: ["serif", "inter", "mono"],
      defaults: {
        template: DEFAULT_TEMPLATE,
        fontFamily: "serif",
        accent: "#000000",
        fontSize: 10,
        lineHeight: 1.2,
        pageMargin: 48,
        showPhoto: false,
      },
      guidance: [
        "Dates use YYYY-MM. Set isCurrent: true instead of an endDate for the current job. A role with startUnconfirmed or endUnconfirmed has a guessed month: write the year alone.",
        "Every experience entry written from a job in Me carries that job's `roleId`, from list_roles. Keep it when you write a document back. It is how trace_resume_evidence knows which job's material may stand behind a bullet — without it, a bullet about one employer can be \"backed\" by another's.",
        "Bullets: strong verb first, specific scope, quantified outcome. One line each where possible.",
        "Harvard house style: no personal pronouns, each bullet a phrase rather than a full sentence, quantified wherever the background gives you a number.",
        "Harvard renders the organisation on line one and the role on line two, so fill in BOTH `company` and `title` on every experience entry, plus `location`.",
        "Section order: experience-first for anyone with real work history. Education-first is the Harvard student convention — use it only for students and recent graduates.",
        "For a 'Leadership & Activities' section, use an experience-kind section with that heading — organisation, role, location and dates all lay out correctly.",
        "In Harvard, education `details` render as plain lines (thesis, relevant coursework, honours), not bullets.",
        "Set visible: false to keep a section in the document but off the page.",
        "Sections and entries carry an `id`. Never invent one — leave it out and the app assigns it — but when you have read a document with get_resume and are writing it back, keep the ids you were given: they are how the editor tells one entry from another.",
        "For length, export_resume_pdf measures the real page count; check_resume_fit then says what to cut. Nothing else is more than an estimate.",
        "Photos: off unless asked. showPhoto draws the user's profile picture (set_profile_photo), never one you supply per document. Harvard never renders one — it is a US academic format and a face on it is wrong. US and UK applications generally omit photos; much of Europe and Latin America expects one.",
      ],
    }),
  },
  {
    name: "list_resumes",
    title: "List resumes",
    description:
      "All saved resumes with their target role/company, how many applications each is attached to, and publicUrl — the shareable link, or null if that resume isn't published. Each row carries outcomes: how many applications sent with it actually went out, how many reached at least a screen, and how many reached an offer — which is what the grid shows; for 'which resume is working?', resume_performance is the one answer, with rates. Interview and offer counts include applications that got there and later closed, not just where things stand today. Favourites come first, then most recently updated. Pass search to narrow by name, target role or target company when the user names a specific one — 'my Stripe resume' is a search, not a reason to fetch everything.",
    inputSchema: object({
      search: str("Case-insensitive filter on name, target role and target company. Omit for all."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const all = await resumes.listResumes(ctx.userId, defined({ search: s(args, "search") }));
      // A row, not the document: every resume's whole JSON went out here, eleven
      // of them at a time. get_resume reads one; the grid reads listResumes
      // directly and keeps `data` for its thumbnails.
      return all.map(({ data: _data, userId: _userId, ...resume }) => ({
        id: resume.id,
        name: resume.name,
        targetRole: resume.targetRole,
        targetCompany: resume.targetCompany,
        template: resume.template,
        isFavorite: resume.isFavorite,
        baseResume: resume.baseResume,
        applications: resume._count.applications,
        outcomes: resume.outcomes,
        publicUrl: publicResumeUrl(ctx.baseUrl, resume.slug),
        createdAt: resume.createdAt,
        updatedAt: resume.updatedAt,
      }));
    },
  },
  {
    name: "get_resume",
    title: "Get a resume",
    description:
      "Fetch one resume: its settings, its full document JSON, publicUrl — the shareable link, or null if it isn't published — what it was tailored from, the variants tailored off it, and every application it was sent to with the stage each reached. Read this before update_resume, which replaces the whole document. The applications are what makes 'is this resume working' answerable from one call.",
    inputSchema: object(
      {
        id: str("Resume id"),
        as_text: bool("Also return a flat plain-text rendering, useful for reviewing length and flow"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const resume = await resumes.getResume(ctx.userId, required(args, "id"));
      if (!resume) throw new Error("Resume not found");
      // `photo` is the resolved image itself; showPhoto already says whether
      // this document uses one, and the base64 helps nobody reading this.
      const { photo, ...row } = resume;
      const withUrl = { ...row, publicUrl: publicResumeUrl(ctx.baseUrl, resume.slug) };
      if (b(args, "as_text")) {
        return {
          ...withUrl,
          text: resumes.resumeToText(resume.doc),
        };
      }
      return withUrl;
    },
  },
  {
    name: "create_resume",
    title: "Create a resume",
    description:
      "Create a resume. Either pass a complete `data` document you have written (call get_resume_format first), or pass seedFromMe: true to auto-populate a first draft from Me and then refine it with update_resume.",
    inputSchema: object(
      {
        name: str("What to call this resume, e.g. 'Stripe — Staff Engineer'"),
        targetRole: str("The role being targeted"),
        targetCompany: str("The company being targeted"),
        template: str(`${TEMPLATE_KEYS.join(" | ")}. Default ${DEFAULT_TEMPLATE}.`),
        accent: str("Accent colour as a hex string. Defaults to '#000000', which is what Harvard expects."),
        fontFamily: str("serif (default) | inter | mono"),
        fontSize: num("Base font size in points, 9-12. Default 10."),
        lineHeight: num("Line height, 1.15-1.6. Default 1.2."),
        notes: str("Private notes about this version — what you tailored and why"),
        showPhoto: bool(
          "Render the user's profile photo in the header. Needs a photo set (see set_profile_photo) and a template that takes one — harvard never does.",
        ),
        seedFromMe: bool("Auto-build a first draft from the knowledge base"),
        baseResumeId: str(
          "Id of the resume this one was tailored from, if any. Records lineage so compare_resumes can show what changed. duplicate_resume sets this automatically — prefer it when the variant starts from an existing document.",
        ),
        data: {
          type: "object",
          description:
            "The full resume document. See get_resume_format for the exact shape. Omit if using seedFromMe.",
          additionalProperties: true,
        },
      },
      ["name"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      resumes.createResume(ctx.userId, {
        name: required(args, "name"),
        seedFromMe: b(args, "seedFromMe"),
        data: args.data,
        ...defined({
          baseResumeId: s(args, "baseResumeId"),
          targetRole: s(args, "targetRole"),
          targetCompany: s(args, "targetCompany"),
          template: s(args, "template"),
          accent: s(args, "accent"),
          fontFamily: s(args, "fontFamily"),
          fontSize: n(args, "fontSize"),
          lineHeight: n(args, "lineHeight"),
          notes: s(args, "notes"),
          showPhoto: b(args, "showPhoto"),
        }),
      }),
  },
  {
    name: "update_resume",
    title: "Update a resume",
    description:
      "Update a resume's settings and/or replace its document. Passing `data` replaces the ENTIRE document, so call get_resume first, modify the JSON you get back, and send the whole thing.",
    inputSchema: object(
      {
        id: str("Resume id"),
        name: str("New name"),
        targetRole: str("Target role"),
        targetCompany: str("Target company"),
        template: str(TEMPLATE_KEYS.join(" | ")),
        accent: str("Accent colour hex"),
        fontFamily: str("serif | inter | mono"),
        fontSize: num("Base font size in points"),
        lineHeight: num("Line height"),
        notes: str("Private notes"),
        isFavorite: bool("Pin this resume to the top"),
        showPhoto: bool(
          "Render the user's profile photo in the header. The picture is the one on their profile, so this only switches it on or off for this document. Harvard ignores it by convention.",
        ),
        data: {
          type: "object",
          description: "The complete replacement resume document",
          additionalProperties: true,
        },
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    versioned: true,
    handler: async (args, ctx) =>
      resumes.updateResume(ctx.userId, required(args, "id"), {
        ...(args.data !== undefined ? { data: args.data } : {}),
        ...defined({
          name: s(args, "name"),
          targetRole: s(args, "targetRole"),
          targetCompany: s(args, "targetCompany"),
          template: s(args, "template"),
          accent: s(args, "accent"),
          fontFamily: s(args, "fontFamily"),
          fontSize: n(args, "fontSize"),
          lineHeight: n(args, "lineHeight"),
          notes: s(args, "notes"),
          isFavorite: b(args, "isFavorite"),
          showPhoto: b(args, "showPhoto"),
        }),
      }, mcpAuthor(ctx, "update_resume")),
  },
  {
    name: "export_resume_pdf",
    title: "Export a resume as a PDF",
    description:
      "Render a resume to a real PDF on the server and return a download url, plus how many pages it actually came out to. Reach for this when the user wants a FILE to attach to an email or upload to a form — use publish_resume instead when they want a link. The page count is measured from the rendered document rather than estimated, so it is the reliable way to answer 'does this fit on one page?' before they send it. The url opens in their browser, where they are already signed in; it is not a public link and nobody else can fetch it. If this instance has no headless browser the tool says so and points at the print page, which produces the same document through the browser's own print dialog.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const id = required(args, "id");
      const resume = await resumes.getResume(ctx.userId, id);
      if (!resume) throw new Error(`No resume with id ${id}`);

      const downloadUrl = `${ctx.baseUrl}/api/resumes/${id}/pdf`;
      if (!pdfRenderingAvailable()) {
        const printUrl = `${ctx.baseUrl}/print/${id}`;
        return withLinks(
          {
            available: false,
            printUrl,
            message:
              "This instance has no headless browser, so it cannot render PDFs server-side. Open the print url and use the browser's Save as PDF.",
          },
          [
            {
              type: "resource_link",
              uri: printUrl,
              name: `${resume.name} (print page)`,
              description: "Opens the US-Letter page; use the browser's Save as PDF.",
              mimeType: "text/html",
            },
          ],
        );
      }

      // Render once here so the answer is "it worked, and it is N pages",
      // not "here is a url, hope it works".
      const token = await createEphemeralSession(ctx.userId);
      try {
        const { bytes, pages } = await renderPdf({
          url: `${ctx.baseUrl}/print/${id}`,
          sessionCookie: {
            name: SESSION_COOKIE,
            value: token,
            domain: new URL(ctx.baseUrl).hostname,
            secure: ctx.baseUrl.startsWith("https:"),
          },
        });
        return withLinks(
          {
            available: true,
            url: downloadUrl,
            pages,
            sizeKb: Math.round(bytes.length / 1024),
            name: resume.name,
          },
          [
            {
              type: "resource_link",
              uri: downloadUrl,
              name: `${resume.name}.pdf`,
              description: `${pages} page${pages === 1 ? "" : "s"}. Opens in the browser they are already signed in to.`,
              mimeType: "application/pdf",
            },
          ],
        );
      } finally {
        await destroySession(token);
      }
    },
  },
  {
    name: "publish_resume",
    title: "Publish a resume to a public link",
    description:
      "Give a resume a shareable web address. Reach for this whenever the user needs a LINK rather than a file — a form's 'resume URL' field, a recruiter asking for something, a message where a PDF would be awkward. Returns publicUrl: hand it straight to the user. Anyone with the link can read it without signing in; the link is the only protection, so it is long, random and not indexed. Private notes on the resume are never shown — but if showPhoto is on, their face is on that page for anyone holding the link: say so first, or offer to turn the photo off. Calling again while published returns the SAME url; publishing after an unpublish mints a new url, and the old one stays dead.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const resume = await resumes.publishResume(ctx.userId, required(args, "id"));
      const publicUrl = publicResumeUrl(ctx.baseUrl, resume.slug);
      const links: ResourceLink[] = publicUrl
        ? [
            {
              type: "resource_link",
              uri: publicUrl,
              name: resume.name,
              description: "Public resume. Anyone holding this link can read it without signing in.",
              mimeType: "text/html",
            },
          ]
        : [];
      return withLinks({ ...resume, publicUrl }, links);
    },
  },
  {
    name: "unpublish_resume",
    title: "Withdraw a resume's public link",
    description:
      "Turn off a resume's public link. Reach for this when the user is done with a link, or has sent one somewhere they regret. The page starts returning 'not found' immediately for everyone who has the url. This is PERMANENT for that address: the link is not parked or paused, it is destroyed, and publishing the same resume later produces a different url. Say so before doing it if the user might still need the old link working. Does not touch the resume itself — nothing is deleted.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => resumes.unpublishResume(ctx.userId, required(args, "id")),
  },
  {
    name: "duplicate_resume",
    title: "Duplicate a resume",
    description:
      "Copy an existing resume so you can make a variant without losing the original. For a job that is on the board, tailor_resume_for_application is the call instead: it copies, names, targets and attaches in one go, and names the copied lines nothing in Me backs. The copy records which resume it came from (baseResumeId), so after tailoring, compare_resumes can show exactly what changed — prefer this over building a tailored document with create_resume from scratch, which loses that trail unless you pass baseResumeId yourself.",
    inputSchema: object({ id: str("Resume id to copy"), name: str("Name for the copy") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => resumes.duplicateResume(ctx.userId, required(args, "id"), s(args, "name")),
  },
  {
    name: "tailor_resume_for_application",
    title: "Start a tailored resume for one job",
    description:
      "Copy the base resume, name it for the job, point it at that company and role, and attach it to the application — four steps in one call. Returns the new resume with its full document, ready for update_resume; what it was based on; and `unbacked`: copied bullets nothing in Me stands behind, including claims corrected in Me since. Rewrite each from Me or drop it in your first update_resume rather than carrying it forward — never invent backing for one. The base is picked for you (the original document, favourite first, not one of its variants); `baseId` overrides it. With no resume on file yet it builds a first draft from Me instead of refusing. Anything already attached to that application is replaced as its resume, not deleted.",
    inputSchema: object(
      {
        applicationId: str("The job this resume is for"),
        baseId: str("Copy this resume instead of the one picked for you"),
        name: str("Name for the copy. Defaults to 'Company — Role'."),
      },
      ["applicationId"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      resumes.createResumeForApplication(ctx.userId, required(args, "applicationId"), {
        ...defined({ baseId: s(args, "baseId"), name: s(args, "name") }),
      }),
  },
  {
    name: "compare_resumes",
    title: "Compare two resumes",
    description:
      "What changed between two resumes — typically a tailored variant against the base it was duplicated from. Pass just id and it compares against the resume's recorded base (duplicate_resume sets that automatically); pass base_id to compare against any other resume. Returns the changed header fields, each section's added and removed bullets and entries, and a one-line summary like '+4 bullets · −2 bullets · summary edited'. A reworded bullet shows as one removed plus one added — old wording beside new, no similarity guessing. Read-only, nothing is written. Reach for this when the user asks what a tailored copy changed, or to review a variant with them before it goes out.",
    inputSchema: object(
      {
        id: str("The tailored resume to inspect"),
        base_id: str(
          "What to compare against. Defaults to the resume's own recorded base; required only when it has none or the user wants a different comparison.",
        ),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const variant = await resumes.getResume(ctx.userId, required(args, "id"));
      if (!variant) throw new Error("Resume not found");
      const baseId = s(args, "base_id") ?? variant.baseResumeId;
      if (!baseId) {
        throw new Error(
          "This resume has no recorded base. Pass base_id to say what to compare it against.",
        );
      }
      const base = await resumes.getResume(ctx.userId, baseId);
      if (!base) throw new Error(`No resume with id ${baseId}`);
      const diff = diffResumeDocs(base.doc, variant.doc);
      return {
        base: { id: base.id, name: base.name },
        variant: { id: variant.id, name: variant.name },
        ...diff,
      };
    },
  },
  {
    name: "set_resume_base",
    title: "Say what a resume was tailored from",
    description:
      "Record that one resume is a tailored copy of another, so compare_resumes can compare them. duplicate_resume sets this for you; this is for documents that already existed, or to re-point one. Pass an empty baseId to unlink. Refuses a loop — two resumes cannot each be the other's base — and refuses to make a resume its own base.",
    inputSchema: object(
      { id: str("The tailored resume"), baseId: str("The resume it came from, or empty to unlink") },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      resumes.setResumeBase(ctx.userId, required(args, "id"), s(args, "baseId") || null),
  },
  {
    name: "trace_resume_evidence",
    title: "Which of your own material backs each bullet",
    description:
      "For every experience bullet in a resume, the highlights from Me that stand behind it, best match first, plus a count of the bullets nothing backs. This is derived by comparing text, not recorded when the document was written — so treat a match as 'this claim is in your notes', not as proof of authorship. The unbacked list is the useful half: those are the lines the person cannot expand on from their own material, which is exactly what to walk through before an interview, and what to ask about before writing anything new. Never invent evidence for a bullet that has none.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => resumes.traceResumeEvidence(ctx.userId, required(args, "id")),
  },
  {
    name: "delete_resume",
    title: "Delete a resume",
    description:
      "Destroy a resume document. Permanent, with no archive behind it. If it was published, its public link dies with it and anyone holding that URL — a recruiter, a form already submitted — gets nothing; the material it was built from stays in Me either way. Prefer duplicate_resume and editing the copy when the point is a different version, rather than deleting and rebuilding. Read the list with list_resumes and name the one you are about to destroy before you call this.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => resumes.deleteResume(ctx.userId, required(args, "id")),
  },
  {
    name: "add_role_to_resume",
    title: "Put a job from Me into a resume",
    description:
      "Add one job already in Me to an existing resume — company, title, dates and its strongest bullets — in one call. Reach for this when a resume is missing a job, or when tailoring calls for a role the draft left out. Use it INSTEAD of update_resume, which replaces the whole document: adding a job that way means reproducing every other word, and a bullet dropped on the way is a loss nobody notices. Name the role by id (from list_roles) or by its company or title, however the user said it. It lands at the end of the experience section unless you give `position`; `bullets` caps how many highlights come with it (six by default). The entry links back to its role, which is what trace_resume_evidence reads. A job already in that resume is refused, and the error says where it is. Returns what was added, where, and how many of the role's bullets were used out of how many — say so, because the rest are there to draw on.",
    inputSchema: object(
      {
        id: str("Resume id"),
        role: str("The role: its id, or the company or title it goes by"),
        section: str(
          "Which experience section, by id or heading. Defaults to the first one — most resumes have exactly one.",
        ),
        position: num("Where among the jobs it lands, 1-based. Defaults to last."),
        bullets: num("How many of the role's highlights to bring. Default 6."),
      },
      ["id", "role"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    versioned: true,
    handler: async (args, ctx) =>
      resumes.addRoleToResume(ctx.userId, required(args, "id"), {
        role: required(args, "role"),
        ...defined({
          section: s(args, "section"),
          position: n(args, "position"),
          bullets: n(args, "bullets"),
        }),
      }, mcpAuthor(ctx, "add_role_to_resume")),
  },
  {
    name: "reorder_resume",
    title: "Move a section, entry or bullet",
    description:
      "Move one piece of a resume — a section, an entry in it, or a bullet in that — to a new position. Use this instead of update_resume whenever only the ORDER changes: update_resume replaces the whole document, so reordering that way means reproducing every word and risks dropping content. Name what moves with `section`, plus `entry` to move an entry, plus `bullet` to move a bullet — the deepest one named moves. Each takes an id, the name it goes by, or its 1-based number, so you can pass what the user said. `position` is 1-based; past the end puts it last. Returns what moved, from where to where, and the resulting section order. If nothing matches, the error lists what is there — read it and retry rather than falling back to update_resume.",
    inputSchema: object(
      {
        id: str("Resume id"),
        section: str("The section: its id, its heading, or its 1-based number"),
        entry: str(
          "An entry inside that section: its id, the company/school/project it names, or its 1-based number. Give this to move the entry, or to say where a bullet lives.",
        ),
        bullet: str(
          "A bullet inside that entry: its text (or enough of it to match) or its 1-based number. Give this to move a bullet.",
        ),
        position: num("Where it lands, 1-based. 1 is first; past the end is last."),
      },
      ["id", "section", "position"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    versioned: true,
    handler: async (args, ctx) =>
      resumes.reorderResume(ctx.userId, required(args, "id"), {
        section: required(args, "section"),
        entry: args.entry === undefined ? undefined : String(args.entry),
        bullet: args.bullet === undefined ? undefined : String(args.bullet),
        position: Number(args.position),
      }, mcpAuthor(ctx, "reorder_resume")),
  },
  {
    name: "check_resume_fit",
    title: "Check what to cut from a resume",
    description:
      "Reach for this when a resume runs long and the question is what to cut. Returns its bullets ranked longest first — each with the entry it sits under and the path to it — and its sections ranked by how much room they take, so the two obvious levers are in front of you: shorten the worst offenders, or hide a whole section. It RANKS; it does not measure. The page count it reports is an estimate that cannot see the type size, leading or margins — only a browser can. Call export_resume_pdf for the real number, then call this to decide what goes. Nothing is written: read it, propose the cuts to the user, and make them with update_resume once they agree. Cutting a bullet is deleting something true they did, so say which ones you would drop and why rather than dropping them.",
    inputSchema: object({ id: str("Resume id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => resumes.resumeFitReport(ctx.userId, required(args, "id")),
  },

  // -------------------------------------------------------------------------
  // PIPELINE
  // -------------------------------------------------------------------------
  {
    name: "prep_letter",
    title: "Gather everything before writing a letter",
    description:
      "Call this FIRST whenever someone asks for a cover letter, a cold message, a referral ask, a thank-you or a reply. Returns `application` (posting and research), `contact` (the recipient), the resume it goes with, `evidence` — matching material from Me, ranked, and the only part a document may use — and `priorLetters`, up to three of this kind they wrote before: the best guide to their voice. Follow `writingRules` without quoting them, never put `neverOnADocument` on the page, and never use a `notSettled` fact. `intent` says what this kind is for; `missing` names what is not on file — say those gaps out loud, and never invent an achievement to fill one. LINKEDIN_ABOUT, HEADLINE, SELF_REVIEW and BRAG_DOC are about the person, so expect no application or recipient: `profile` holds their current headline and summary, `evidence` falls back to their roles without a `topic`, and `priorLetters` is the previous version — on a brag doc, add to it rather than replace it. Read-only, saves nothing.",
    inputSchema: object({
      kind: {
        type: "string",
        enum: [...LETTER_KINDS],
        description: "What you are about to write. Defaults to COVER_LETTER",
      },
      application_id: str("The job this is about, for the posting, the research and the timeline"),
      contact_id: str("The person it is going to, for their name and how they are known"),
      topic: str(
        "What this is about, when it is not about a posting — \"the last six months\", \"the billing migration\", \"the two people I mentored\". Used to find the matching material in Me. Leave it out on a self-review or a brag doc and you get their roles, newest first, which is usually what you want",
      ),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      letters.letterContext(ctx.userId, {
        kind: enumArg(args, "kind", LETTER_KINDS) as LetterKind | undefined,
        topic: s(args, "topic"),
        applicationId: s(args, "application_id"),
        contactId: s(args, "contact_id"),
      }),
  },
  {
    name: "create_letter",
    title: "Save a letter",
    description:
      "Save a cover letter, a cold message, a referral ask, a thank-you or a reply. Call prep_letter first — a letter written without the posting, the evidence and their own earlier letters is the generic one everybody sends, and it is worse than nothing. The body is plain prose, in their voice, and every claim in it has to trace back to something in Me: never invent an employer, a date, a metric or a project. Links are optional but worth setting — application_id files it under the job, contact_id under the person, resume_id records which document it went out with. Leave sent_at unset while it is a draft; the list says which are still drafts. Returns the saved letter with its id.",
    inputSchema: object(
      {
        kind: { type: "string", enum: [...LETTER_KINDS], description: "What this is. Defaults to COVER_LETTER" },
        title: str("What to call it, e.g. 'Anthropic — Staff Engineer'"),
        body: str("The letter itself, as prose. Paragraphs separated by blank lines"),
        recipient: str("Who it is addressed to, in their words: 'Priya, engineering manager', 'the hiring team'"),
        application_id: str("The job this is for"),
        contact_id: str("The person it is going to"),
        resume_id: str("The resume it goes out with"),
        sent_at: str("The day it was actually sent, YYYY-MM-DD. Leave unset for a draft"),
      },
      ["body"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => letters.createLetter(ctx.userId, letterInputFrom(args)),
  },
  {
    name: "list_letters",
    title: "List letters",
    description:
      "Everything they have written that is not a resume, newest first, each with the job, person and resume it is attached to. Filter by kind, by application, by contact, by whether it is still a draft, or by a word in the text. A letter attached to an archived application does not appear, the same way that application's tasks and timeline do not. Use this to find something to reuse before writing from scratch, and to answer 'did I ever reply to them'. Read-only.",
    inputSchema: object({
      kind: { type: "string", enum: [...LETTER_KINDS], description: "Only this kind" },
      application_id: str("Only letters filed under this job"),
      contact_id: str("Only letters to this person"),
      drafts_only: bool("Only the ones never marked sent"),
      search: str("A word in the title, body or recipient"),
      limit: limitArg(50),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await letters.listLetters(ctx.userId, {
          kind: enumArg(args, "kind", LETTER_KINDS) as LetterKind | undefined,
          applicationId: s(args, "application_id"),
          contactId: s(args, "contact_id"),
          draftsOnly: b(args, "drafts_only"),
          search: s(args, "search"),
        }),
        n(args, "limit"),
        50,
      ),
  },
  {
    name: "get_letter",
    title: "Read one letter",
    description:
      "The full text of one letter, with the job, person and resume it is attached to. Read this before updating one — update_letter REPLACES the body you send.",
    inputSchema: object({ id: str("Letter id, from list_letters") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const letter = await letters.getLetter(ctx.userId, required(args, "id"));
      if (!letter) throw new Error(`No letter with id ${required(args, "id")}`);
      return letter;
    },
  },
  {
    name: "update_letter",
    title: "Update a letter",
    description:
      "Change a saved letter. Only the fields you send change — but sending `body` REPLACES the whole body, so call get_letter first, edit what came back, and write it back whole. This is also how a draft becomes sent: set sent_at to the day it went. Send an empty string to a link to detach it.",
    inputSchema: object(
      {
        id: str("Letter id"),
        kind: { type: "string", enum: [...LETTER_KINDS], description: "Change what this is" },
        title: str("What to call it"),
        body: str("The whole letter. Replaces what is there"),
        recipient: str("Who it is addressed to"),
        application_id: str("The job this is for. Empty string detaches"),
        contact_id: str("The person it is going to. Empty string detaches"),
        resume_id: str("The resume it goes out with. Empty string detaches"),
        sent_at: str("The day it was sent, YYYY-MM-DD. Empty string puts it back to a draft"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      letters.updateLetter(ctx.userId, required(args, "id"), letterInputFrom(args)),
  },
  {
    name: "export_letter_pdf",
    title: "Export a letter as a PDF",
    description:
      "Render a letter to a real PDF on the server and return a download url. Reach for this when they are about to send one — a form wants a file, or they are attaching it to an email. It carries their name and contact details as a letterhead, today's date in THEIR time zone, and the body exactly as written; nothing is rewritten or added. The url opens in their signed-in browser and is not public; a letter cannot be published the way a resume can. With no headless browser on this instance it says so and returns the print url, which gives the same document through Save as PDF. LINKEDIN_ABOUT, HEADLINE, SELF_REVIEW and BRAG_DOC print without date or recipient; a headline is one line, so ask before spending a page on it.",
    inputSchema: object({ id: str("Letter id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const id = required(args, "id");
      const letter = await letters.getLetter(ctx.userId, id);
      if (!letter) throw new Error(`No letter with id ${id}`);

      const name = letter.title || letters.LETTER_LABEL[letter.kind];
      const printUrl = `${ctx.baseUrl}/print/letter/${id}`;
      if (!pdfRenderingAvailable()) {
        return withLinks(
          {
            available: false,
            printUrl,
            message:
              "This instance has no headless browser, so it cannot render PDFs server-side. Open the print url and use the browser's Save as PDF.",
          },
          [
            {
              type: "resource_link",
              uri: printUrl,
              name: `${name} (print page)`,
              description: "Opens the US-Letter page; use the browser's Save as PDF.",
              mimeType: "text/html",
            },
          ],
        );
      }

      // Rendered here rather than just handing back a url, so the answer is
      // "it worked", not "here is an address, hope it works" — the same
      // reasoning export_resume_pdf is built on.
      const token = await createEphemeralSession(ctx.userId);
      try {
        const { bytes, pages } = await renderPdf({
          url: printUrl,
          marker: ".letter-paper",
          sessionCookie: {
            name: SESSION_COOKIE,
            value: token,
            domain: new URL(ctx.baseUrl).hostname,
            secure: ctx.baseUrl.startsWith("https:"),
          },
        });
        const downloadUrl = `${ctx.baseUrl}/api/letters/${id}/pdf`;
        return withLinks(
          {
            available: true,
            url: downloadUrl,
            pages,
            sizeKb: Math.round(bytes.length / 1024),
            name,
          },
          [
            {
              type: "resource_link",
              uri: downloadUrl,
              name: `${name}.pdf`,
              description: `${pages} page${pages === 1 ? "" : "s"}.`,
              mimeType: "application/pdf",
            },
          ],
        );
      } finally {
        await destroySession(token);
      }
    },
  },
  {
    name: "delete_letter",
    title: "Delete a letter",
    description:
      "Remove a letter for good. There is no archive for letters — this is gone, and what somebody said to an employer is not usually worth losing. Say what will go and get a plain yes first.",
    inputSchema: object({ id: str("Letter id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => letters.deleteLetter(ctx.userId, required(args, "id")),
  },
  {
    name: "pipeline_stats",
    title: "Pipeline stats",
    description:
      "Counts by stage, applications in flight, sent this week, interviewing and the deepest round reached, offers, open tasks, follow-ups due and response rate. Start here for any 'how is my search going' question. `active` counts applications actually sent and still alive — wishlist rows are not in it. `responseRate` counts an application by how far it ever got, so an interview then a rejection is a response. `responseRateBasis` is how many applications the rate rests on: under about ten it describes luck, so say the counts rather than quoting a percentage at somebody who has applied to three things.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => pipeline.pipelineStats(ctx.userId),
  },
  {
    name: "list_applications",
    title: "List applications",
    description:
      "List job applications, one summary row each — no posting text, notes cut to a line with their full length in notesChars; get_application returns the whole record. Closed ones (ACCEPTED and LOST) are excluded by default. Two numbers answer different questions: daysInStage is time since the last stage change; quietDays is time since ANYTHING happened — a call, an email, a stage move — counted from lastTouchAt. 'What has gone quiet' is quietDays, and quietForDays returns only those past it — the fastest answer to 'what needs chasing'. Capped at 100 rows unless you raise `limit`, and a cut-off result says how many there were before the data, so narrow the filters rather than reporting a short list as everything.",
    inputSchema: object({
      stage: { type: "string", enum: STAGE_VALUES, description: "Only this stage" },
      includeClosed: bool("Include accepted / rejected / withdrawn"),
      search: str("Filter by company, role title or notes"),
      quietForDays: num("Only those with no activity for at least this many days"),
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        (
          await pipeline.listApplications(ctx.userId, {
            stage: s(args, "stage") as Stage | undefined,
            includeClosed: b(args, "includeClosed"),
            search: s(args, "search"),
            quietForDays: n(args, "quietForDays"),
          })
        ).map(listRow),
        n(args, "limit"),
        100,
      ),
  },
  {
    name: "get_application",
    title: "Get an application",
    description:
      "Full detail for one application including the job description, the complete activity timeline, contacts and tasks. Also carries quietDays and lastTouchAt — how long since anything happened, and when.",
    inputSchema: object({ id: str("Application id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const application = await pipeline.getApplication(ctx.userId, required(args, "id"));
      if (!application) throw new Error(`No application with id ${required(args, "id")}`);
      return application;
    },
  },
  {
    name: "capture_job_posting",
    title: "Capture job postings from their URLs",
    description:
      "The FIRST tool to call when someone shares a link to a job posting — or twenty, from a morning of open tabs. Pass `url` for one or `urls` for several. Fetches each page server-side and creates the application in one move, on the wishlist: company matched or created, role title, full description, location, compensation and source filled; a company already on file only has its empty fields filled. THE SAME ROLE IS NEVER CREATED TWICE: a live application with the same employer and title comes back as a duplicate naming that row, within a list too. When a page does not state the employer or the role readably, nothing is created and whatever WAS parsed comes back — show it, ask for the missing pieces, and use create_application. Never guess an employer from a URL. With `url`: returns captured true and the application, or captured false with `duplicate` or a reason. With `urls`: three lists — captured, duplicates and failed — with every URL in exactly one; report all three, not a count. If they already applied, follow with move_application_stage.",
    inputSchema: object({
      url: str("One posting's URL, e.g. a Greenhouse, Lever, Ashby, Workday or LinkedIn job link"),
      urls: strArray("Several postings' URLs. Duplicates within the list are collapsed before anything is fetched."),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) => {
      const many = Array.isArray(args.urls) ? (args.urls as unknown[]).map(String) : [];
      const one = s(args, "url");
      if (many.length > 0) return pipeline.captureJobPostings(ctx.userId, one ? [one, ...many] : many);
      if (one) return pipeline.captureJobPosting(ctx.userId, one);
      throw new Error("Pass url for one posting, or urls for several.");
    },
  },
  {
    name: "watch_company_board",
    title: "Watch a company's jobs board",
    description:
      "Watch one employer's own jobs board and hear when a matching role appears — the tool for 'tell me when Stripe posts a staff engineer role'. Reads Greenhouse, Lever and Ashby: pass the board link or the company's careers page, and an embedded board is followed through. ANY OTHER BOARD IS REFUSED and nothing is saved — for Workday, SmartRecruiters or a hand-built page, browse it and paste links into capture_job_posting. THE FIRST LOOK PROPOSES NOTHING: what is on the board today is recorded as already seen, unless propose_existing queues the first ten matches. After that, each new matching role becomes a proposal on their dashboard, posting already read, to accept onto the wishlist or dismiss. NOTHING REACHES THE PIPELINE WITHOUT A YES. Returns the watch, the provider and how many roles the board carries today — read that number back, since a big board with no filters is a noisy watch. Calling again for the same board changes its filters rather than adding a watch. A `scheduleNote` means nothing on this instance checks watched boards on a schedule yet — say so, because a watch nobody runs hears about nothing.",
    inputSchema: object(
      {
        board_url: str(
          "The board or careers page — boards.greenhouse.io/acme, jobs.lever.co/acme, jobs.ashbyhq.com/acme, or acme.com/careers",
        ),
        company_id: str("The company on file this board belongs to, from list_companies. Preferred"),
        company: str(
          "Their name, when there is no id yet. Matched against the companies on file before anything is created",
        ),
        title_terms: strArray(
          "A role's title has to contain ANY of these, case-insensitively, e.g. ['staff engineer', 'principal engineer']. Empty means every role on the board",
        ),
        location_terms: strArray(
          "AND its location or work mode has to contain ANY of these, e.g. ['remote', 'new york']. Empty means anywhere",
        ),
        propose_existing: bool(
          "Queue up to ten roles already on the board as well. Default false, which makes the first look a baseline",
        ),
      },
      ["board_url"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) => {
      const result = await watch.watchCompanyBoard(ctx.userId, {
        boardUrl: required(args, "board_url"),
        ...defined({
          companyId: s(args, "company_id"),
          company: s(args, "company"),
          titleTerms: a(args, "title_terms"),
          locationTerms: a(args, "location_terms"),
          proposeExisting: b(args, "propose_existing"),
        }),
      });
      // A watch is only as good as the scheduler that runs it. Say so here,
      // where the promise is made, rather than letting it fail silently.
      const { note } = await scheduled.scheduleState("boards");
      return result.watched && note ? { ...result, scheduleNote: note } : result;
    },
  },
  {
    name: "list_company_watches",
    title: "Which boards are being watched",
    description:
      "Every company board this person is watching: the employer, which provider it reads, the title and location filters on it, when it was last looked at, how many roles it has proposed, and anything that went wrong the last time. Call it before adding a watch, so you change the filters on the one that already exists instead of making a second. It is also the answer to \"why have I not heard anything about Acme\" — a watch carrying `lastError` has been failing quietly, and one with a `lastFoundAt` of never almost always has filters narrower than the board's own wording. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => watch.listCompanyWatches(ctx.userId),
  },
  {
    name: "update_company_watch",
    title: "Change a board watch's filters",
    description:
      "Change what a watch is looking for, or park it without losing what it has already seen. `title_terms` and `location_terms` REPLACE the lists they are given — send the whole list, not the one term being added, and call list_company_watches first to see what is on there. Widening the filters does not re-offer roles the watch has already decided about: everything on the board the first time it looked is marked seen for good, so a wider filter finds new postings rather than old ones. Setting enabled false keeps the row and stops the looking, which is what somebody wants during a heavy month; unwatch_company_board is for being done with it.",
    inputSchema: object(
      {
        id: str("Watch id, from list_company_watches"),
        title_terms: strArray("Replaces the title filter. An empty list means every role"),
        location_terms: strArray("Replaces the location filter. An empty list means anywhere"),
        enabled: bool("False parks it without deleting it"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      watch.updateCompanyWatch(
        ctx.userId,
        required(args, "id"),
        defined({
          titleTerms: a(args, "title_terms"),
          locationTerms: a(args, "location_terms"),
          enabled: b(args, "enabled"),
        }),
      ),
  },
  {
    name: "unwatch_company_board",
    title: "Stop watching a company's board",
    description:
      "Stop watching a board. The watch is deleted outright — there is no archive for one, and this cannot be undone; watching the same board again starts from a fresh baseline, so nothing already on it will be offered. Proposals it has already queued are left exactly where they are, for them to accept or dismiss, and nothing else about the company, its applications or its people is touched. Say whose board you are about to stop watching before you call it.",
    inputSchema: object({ id: str("Watch id, from list_company_watches") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => watch.unwatchCompanyBoard(ctx.userId, required(args, "id")),
  },
  {
    name: "check_company_board",
    title: "Look at a watched board now",
    description:
      "Read a watched board now instead of waiting for the schedule, and queue anything new — the tool for 'anything new at Acme?'. It matches the filters against roles the watch has not yet decided about and queues up to ten as proposals, posting read into each. NOTHING REACHES THE PIPELINE: roles wait in the review queue, and accept_proposal creates the application — never follow this with accept_proposal unless the person said yes to that role. Returns how many roles the board carries, how many matched, were queued, and were skipped as already seen. With no id it checks every watch, least recently checked first, a few seconds apart. An instance with /api/sweep/<token> on a schedule does this unasked; this jumps the queue.",
    inputSchema: object({
      id: str("One watch, from list_company_watches. Omit to check them all"),
      limit: num("How many watches to check when no id is given. Default 20"),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: true,
    },
    handler: async (args, ctx) =>
      watch.checkCompanyBoards(ctx.userId, defined({ watchId: s(args, "id"), limit: n(args, "limit") })),
  },
  {
    name: "check_posting_live",
    title: "Check whether a posting is still up",
    description:
      "Fetch the posting behind an application and report whether it is still up — a 404 is usually the first sign a role was filled or pulled, weeks before anybody writes. Pass application_id for one, or nothing to work through the open applications with a link, least recently checked first. THIS NEVER MOVES A STAGE AND NEVER LOGS TO A TIMELINE: it records the posting's state on the application — LIVE, GONE, UNCLEAR or UNREACHABLE, with the date and what the host said — and stops. GONE needs TWO 404s a day apart; a single one reads UNCLEAR, as does a page that answers without naming a role, which client-rendered boards do whether the job exists or not. UNREACHABLE says nothing about the role. Returns a line per application and how many are gone. When something is GONE, tell them plainly and offer log_activity or ask where it stands — do not decide for them, and do not call move_application_stage off the back of this.",
    inputSchema: object({
      application_id: str("One application. Omit to work through the open ones"),
      limit: num("How many applications to check when no id is given. Default 25"),
      force: bool(
        "Check even one looked at in the last three days. Use when they are asking about a specific job right now",
      ),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) =>
      watch.checkPostings(
        ctx.userId,
        defined({
          applicationId: s(args, "application_id"),
          limit: n(args, "limit"),
          force: b(args, "force"),
        }),
      ),
  },
  {
    name: "record_offer",
    title: "Record an offer",
    description:
      "Save what somebody actually offered: base, bonus, equity, sign-on, terms in words, and the date they must answer by. Call it the moment a number is said out loud, even a verbal one. EVERY CALL WRITES A NEW VERSION rather than replacing the last: when they come back with more, call this again, and the rows become the negotiation record. update_offer is only for fixing a typo. Amounts are WHOLE UNITS — 215000, and '215k' is understood; equity is the yearly value where they stated one, with the schedule in vesting. Never invent a number: if they said 'competitive', record nothing and say the base is still unknown. This does NOT move the application to the offer stage; check the stage that comes back and call move_application_stage if it is behind. respond_by is what puts the deadline in their notifications.",
    inputSchema: object(
      {
        application_id: str("Which application this offer is for"),
        base_amount: num("Annual base salary, whole units, e.g. 215000"),
        bonus_amount: num("Target annual bonus in the same units. Omit when none was stated"),
        equity_amount: num("Yearly value of the equity grant where they stated one. Leave out when they only described a percentage — put that in terms"),
        sign_on_amount: num("One-off signing bonus"),
        currency: str("Three-letter code, e.g. USD, GBP, EUR. Defaults to USD"),
        terms: str("Everything the numbers cannot hold, in their words: '0.4% over four years, one year cliff', 'relocation on top', 'review at six months'"),
        vesting: str("The vesting schedule, e.g. '4 years, 1 year cliff, monthly after'"),
        received_at: str("The day it was put on the table, YYYY-MM-DD. Defaults to today"),
        respond_by: str("The day they need an answer, YYYY-MM-DD. This is what the notification bell reads"),
        starts_on: str("Proposed start date, YYYY-MM-DD"),
      },
      ["application_id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      offers.recordOffer(ctx.userId, required(args, "application_id"), offerInputFrom(args)),
  },
  {
    name: "list_offers",
    title: "List offers",
    description:
      "Every offer on file, newest first, each with the application and company it belongs to. Several rows for one application are its versions, not several offers — the first one recorded is the opening number and the newest is where it stands now. Pass application_id for one job's history, or live_only to drop the ones that came to nothing. For a side-by-side of what is actually on the table, compare_offers is the better tool; for arguing one of them, offer_briefing. Read-only.",
    inputSchema: object({
      application_id: str("Only this application's offers — every version of it, newest first"),
      live_only: bool("Drop offers whose application was lost. An accepted one stays — it is the one they took"),
      limit: limitArg(50),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await offers.listOffers(ctx.userId, {
          applicationId: s(args, "application_id"),
          liveOnly: b(args, "live_only"),
        }),
        n(args, "limit"),
        50,
      ),
  },
  {
    name: "update_offer",
    title: "Fix an offer row",
    description:
      "Correct a recorded offer in place — a base typed with a digit missing, a respond-by that moved, a vesting schedule nobody wrote down. Only the fields you send change. THIS IS NOT FOR A NEW NUMBER: when they improved the offer, call record_offer again, because overwriting the old row destroys the only record that the number moved. Get the id from list_offers.",
    inputSchema: object(
      {
        id: str("Offer id, from list_offers"),
        base_amount: num("Annual base salary, whole units"),
        bonus_amount: num("Target annual bonus"),
        equity_amount: num("Yearly value of the equity grant"),
        sign_on_amount: num("One-off signing bonus"),
        currency: str("Three-letter code, e.g. USD"),
        terms: str("Everything the numbers cannot hold. Replaces what is there"),
        vesting: str("The vesting schedule. Replaces what is there"),
        received_at: str("The day it was put on the table, YYYY-MM-DD"),
        respond_by: str("The day they need an answer, YYYY-MM-DD. Send an empty string to clear it"),
        starts_on: str("Proposed start date, YYYY-MM-DD. Send an empty string to clear it"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      offers.updateOffer(ctx.userId, required(args, "id"), offerInputFrom(args)),
  },
  {
    name: "delete_offer",
    title: "Delete an offer row",
    description:
      "Remove one recorded offer for good. There is no archive for offers — this is gone. Use it for a row entered against the wrong job, not for an offer that was withdrawn: a withdrawn offer is part of the story, so leave the row and move the application to lost with a loss tag instead. Say what will go and get a plain yes first.",
    inputSchema: object({ id: str("Offer id, from list_offers") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => offers.deleteOffer(ctx.userId, required(args, "id")),
  },
  {
    name: "get_outbound_settings",
    title: "Can this workspace send mail",
    description:
      "Whether this app is allowed to send a message from this person's own mailbox, how many a day they set, whether each one waits for a click, which mailboxes can actually send, how many have gone today and what is standing in the way right now. `blockedBecause` is the whole answer when it is non-empty — read it back rather than trying the send and reporting a failure. Three separate things have to be true: an admin turned it on for the instance, the person set a daily number above zero themselves, and a Google or Microsoft mailbox is connected WITH the send permission (an IMAP account cannot send at all, whatever else it does). Read-only, and it sends nothing.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => outbound.getOutboundSettings(ctx.userId),
  },
  {
    name: "draft_outbound_email",
    title: "Draft a message to send from their own mailbox",
    description:
      "Write a message and put it in the outbox as a DRAFT. IT IS NOT SENT — nothing here sends anything; send_outbound_email is a separate call with its own refusals. For the follow-up nobody gets round to: the chase after a week of silence, the thank-you after an interview, the reply to a recruiter. It goes from ONE OF THEIR OWN MAILBOXES, so it arrives from them. IT CAN ONLY GO TO SOMEBODY ALREADY ON FILE: pass contact_id and the address is read off their record — no argument takes an address. To mail somebody new, create the contact first and say so. Write in THEIR voice from what is on file — call prep_letter or search_me first for anything substantial — and never invent a meeting, a name, a date, a number or an agreement. Keep it short; create_letter is for anything that wants a title and a draft history. Returns the draft id, the mailbox it will go from, what it says, and whether it needs approving: by default every message waits for a click in the app that NO TOOL CAN MAKE, and the result says so.",
    inputSchema: object(
      {
        contact_id: str("Who it is to. The address comes off their record — there is no way to pass one"),
        subject: str("The subject line"),
        body: str("The message, plain text, in their voice"),
        account_id: str("Which of their mailboxes to send from. Omit to use the only one that can send"),
        application_id: str("The job this is about, so the send lands on that job's timeline"),
        letter_id: str("A letter this was drafted from, if there is one. Its sentAt is stamped when this goes"),
      },
      ["contact_id", "subject", "body"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      outbound.draftOutbound(
        ctx.userId,
        {
          contactId: required(args, "contact_id"),
          subject: required(args, "subject"),
          body: required(args, "body"),
          ...defined({
            accountId: s(args, "account_id"),
            applicationId: s(args, "application_id"),
            letterId: s(args, "letter_id"),
          }),
        },
        mcpAuthor(ctx, "draft_outbound_email"),
      ),
  },
  {
    name: "list_outbound",
    title: "What is in the outbox",
    description:
      "Every message this app has drafted, sent, failed or had cancelled for this person, newest first — with what it said, who it went to, which mailbox it went from, which connection drafted it and which sent it. Pass status to narrow it: DRAFT is waiting, APPROVED has been clicked and is ready, SENT has gone, FAILED says why in `error`, CANCELLED was called off. Reach for it before drafting anything, so you do not send a second chase to somebody who was chased on Tuesday, and read what went before to match how they actually write. The body is kept after sending, deliberately: a record of what you said is the thing this product exists to keep. Read-only.",
    inputSchema: object({
      status: {
        type: "string",
        enum: [...OUTBOUND_STATUSES],
        description: "DRAFT, APPROVED, SENT, FAILED or CANCELLED. Omit for all of them",
      },
      application_id: str("Only messages about this job"),
      contact_id: str("Only messages to this person"),
      limit: limitArg(50),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await outbound.listOutbound(ctx.userId, {
          ...defined({
            status: enumArg(args, "status", OUTBOUND_STATUSES) as OutboundStatus | undefined,
            applicationId: s(args, "application_id"),
            contactId: s(args, "contact_id"),
          }),
          limit: n(args, "limit") ?? 50,
        }),
        n(args, "limit"),
        50,
      ),
  },
  {
    name: "send_outbound_email",
    title: "Send a drafted message",
    description:
      "Actually send a draft, from the person's own mailbox. THIS IS THE ONE THING IN THIS APP THAT PUTS A MESSAGE IN FRONT OF SOMEBODY OTHER THAN ITS OWNER. It refuses, by name, when: the instance does not allow members to send; they have not turned sending on and set a daily number; no mailbox can send; they are at their daily limit; one went less than a minute ago; the person or job is archived; or they are in \"approve each one\" mode and have not clicked approve — a click NO TOOL CAN MAKE, this one included. A refusal is `sent` false with a `reason` to read out; it is not an error and not something to work around. On success it stamps the source letter as sent, writes an EMAIL_SENT activity on the job's timeline, and returns the provider's message id. AN EMAIL CANNOT BE UNSENT: say who it goes to, from which mailbox and what it says, and get a plain yes every time — even in trusted mode. Never send a draft the person has not seen.",
    inputSchema: object({ id: str("Draft id, from draft_outbound_email or list_outbound") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: true,
    },
    handler: async (args, ctx) =>
      outbound.sendOutbound(ctx.userId, required(args, "id"), {
        writtenBy: "mcp",
        connectionId: ctx.connectionId,
        connectionName: ctx.connectionName,
        tool: "send_outbound_email",
      }),
  },
  {
    name: "cancel_outbound_email",
    title: "Call off a drafted message",
    description:
      "Mark a draft, an approved message or a failed one as cancelled so it can never go. Reach for it when they change their mind, when a draft is wrong enough to rewrite from scratch, or when a failed send should not be retried. The row stays with everything it said — nothing here deletes the record — and a cancelled message cannot be un-cancelled, so draft a new one instead. It does nothing to a message that has already gone, because nothing can.",
    inputSchema: object({ id: str("Message id, from list_outbound") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => outbound.cancelOutbound(ctx.userId, required(args, "id")),
  },
  {
    name: "attach_file",
    title: "Keep a file against a job, a person or an offer",
    description:
      "Store a file against exactly one thing: an application, an offer, a letter, a person or a company — the signed offer letter, the take-home they submitted, a recruiter's PDF, a screenshot of a posting since taken down. Pass exactly one of application_id, offer_id, letter_id, contact_id or company_id; there is no way to store a file loose, and nothing in the archive can take one. Send the bytes as `data_uri` when you hold them, or `url` for an https link the server fetches — MUCH cheaper, and the one to prefer when the file is on the web. Default limits are 8MB a file and 250MB a workspace; over either it refuses and says how much is in use. Accepts PDFs, images, plain text, markdown, CSV, JSON, zips and Word or Excel files, and THE TYPE IS READ FROM THE FILE'S OWN BYTES, not its name. The same bytes twice return the first row with `reused` true, so retrying after a timeout is safe. Returns the file's id, size, space in use and a download link that opens in their signed-in browser and is not public. IT NEVER RETURNS THE BYTES; read_attachment reads a text file.",
    inputSchema: object(
      {
        filename: str("What the file is called, e.g. 'northwind-offer-signed.pdf'"),
        data_uri: str("The file inline: 'data:application/pdf;base64,…'. Use url instead where you can"),
        url: str("An https link the server fetches. Cheaper than sending the bytes"),
        caption: str("One line about what it is, in their words: 'the offer as signed'"),
        application_id: str("The job it belongs to"),
        offer_id: str("The offer it belongs to"),
        letter_id: str("The letter it went out with"),
        contact_id: str("The person it came from"),
        company_id: str("The company it is about"),
      },
      ["filename"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) => {
      const result = await attachments.createAttachment(ctx.userId, {
        filename: required(args, "filename"),
        ...defined({
          dataUri: s(args, "data_uri"),
          url: s(args, "url"),
          caption: s(args, "caption"),
          ...attachmentSubjectFrom(args),
        }),
      });
      const url = `${ctx.baseUrl}/api/attachments/${result.attachment.id}`;
      return withLinks({ ...result, url }, [
        {
          type: "resource_link",
          uri: url,
          name: result.attachment.filename,
          description: `${Math.max(1, Math.round(result.attachment.size / 1000))}KB. Opens in the browser they are already signed in to.`,
          mimeType: result.attachment.mimeType,
        },
      ]);
    },
  },
  {
    name: "list_attachments",
    title: "List kept files",
    description:
      "Every file kept in this workspace, or just the ones hanging off one thing — pass application_id, offer_id, letter_id, contact_id or company_id to narrow it. Reach for this before attach_file when somebody says \"the offer letter\": it is probably already here. Returns the id, name, type, size, caption and what each is attached to, plus a download link for each; it never returns the bytes, so this is cheap to call on a workspace with two hundred files. Files whose job, person or company is in the archive are NOT listed — restore the record and they come back with it. Capped, and it says so when the list was cut off.",
    inputSchema: object({
      application_id: str("Only files on this job"),
      offer_id: str("Only files on this offer"),
      letter_id: str("Only files on this letter"),
      contact_id: str("Only files on this person"),
      company_id: str("Only files on this company"),
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const rows = await attachments.listAttachments(ctx.userId, {
        ...attachmentSubjectFrom(args),
        limit: n(args, "limit") ?? 100,
      });
      return capped(
        rows.map((row) => ({ ...row, url: `${ctx.baseUrl}/api/attachments/${row.id}` })),
        n(args, "limit"),
        100,
      );
    },
  },
  {
    name: "read_attachment",
    title: "Read a kept file",
    description:
      "The TEXT of a kept file, for when you actually have to read one — a take-home brief they saved as markdown, a CSV of interview questions, a JSON export somebody sent. Only text files come back with text: a PDF, an image, a zip or a Word document returns `text` null and its download link instead, because there is no honest way to put a binary in a tool result and a base64 string of one is a context window somebody else paid for. Long files are cut and `truncated` says so. Read-only — this never changes the file or the record it hangs off.",
    inputSchema: object(
      {
        id: str("Attachment id, from list_attachments"),
        limit: num("How many characters of text to return. Default 40,000, which is the ceiling too"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const result = await attachments.readAttachmentText(
        ctx.userId,
        required(args, "id"),
        n(args, "limit"),
      );
      return {
        ...result,
        url: `${ctx.baseUrl}/api/attachments/${result.attachment.id}`,
        note:
          result.text === null
            ? `${result.attachment.mimeType} is not something that can be read as text. The link opens it.`
            : "",
      };
    },
  },
  {
    name: "delete_attachment",
    title: "Delete a kept file",
    description:
      "Remove one file for good. THERE IS NO ARCHIVE FOR A FILE — this destroys the bytes, and nothing else in the app has a second copy of them. Say which file, and what it was attached to, and get a plain yes first. Use it to free space when a workspace is at its limit, and say how much it got back. Deleting the job, person or company a file hangs off destroys it too, which is what makes a file impossible to orphan.",
    inputSchema: object({ id: str("Attachment id, from list_attachments") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const done = await attachments.deleteAttachment(ctx.userId, required(args, "id"));
      return { ...done, usage: await attachments.attachmentUsage(ctx.userId) };
    },
  },
  {
    name: "compare_offers",
    title: "Compare the offers on the table",
    description:
      "Every live offer side by side: one column per job with its newest version, sorted by what year one actually pays. Each column has base, bonus, equity, sign-on, yearlyTotal (sign-on excluded), firstYearTotal (sign-on included), the deadline and days left, how many revisions, and `leads` naming the lines it wins outright. IT WILL NOT CONVERT CURRENCIES: across currencies `comparable` is false with the reason and nothing leads — say so and ask which currency they want rather than picking a rate. Report terms and vesting alongside the numbers; a shorter cliff or a real start date can outweigh a bigger base. Read-only.",
    inputSchema: object({
      application_ids: strArray("Only these applications. Omit for everything still on the table"),
      include_lost: bool("Also include offers from applications that ended in a loss, for a historical comparison"),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      offers.compareOffers(ctx.userId, {
        applicationIds: a(args, "application_ids"),
        includeLost: b(args, "include_lost"),
      }),
  },
  {
    name: "offer_briefing",
    title: "Everything on file for one negotiation",
    description:
      "Call this BEFORE helping somebody answer or negotiate an offer. One read gathers every version of the offer; `advertised`, what the POSTING claimed, which is not what anybody offered; `saidDuringProcess`, timeline lines where money came up, each with its activity id; `pastPay`, what they have written in Me about their earnings; and `competing`, the other offers on the table. `missing` names what is not on file — an unrecorded base, a vesting schedule, a deadline. Quote the evidence back; never state a number that is not in this result, and never pass off a guessed market rate as their data. Read-only, saves nothing.",
    inputSchema: object({ application_id: str("Which application to brief on") }, ["application_id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      offers.offerBriefing(ctx.userId, required(args, "application_id")),
  },
  // -------------------------------------------------------------------------
  // INTERVIEWS — rounds, what was asked, and what you said
  // -------------------------------------------------------------------------
  {
    name: "prep_interview",
    title: "Gather everything before an interview",
    description:
      "Call this FIRST whenever somebody has an interview coming up, asks what to expect, or asks you to run prep. One read returns `application` (posting, stage, last ten logged events), `company` research, `interviewers`, `history` (earlier rounds here and how each went), `employerQuestions` (what THIS employer already asked — read these out first), `commonQuestions`, `weakAnswers` (unanswered or rated badly — the homework), `tasks` from the stage checklist, and `missing`: what is not on file. Say the gaps out loud, and NEVER invent a story, an employer, a date or a metric to fill one — if it is not in the result, it does not go in an answer. Hand back the three things this employer cares about most, the strongest TRUE story for each with its real numbers, likely questions and weak spots to rehearse, questions to ask them, and any follow-up the timeline says is owed. Pass interview_id for a booked round, or application_id when no date is set. Read-only, saves nothing.",
    inputSchema: object({
      application_id: str("The job to prep for. Enough on its own."),
      interview_id: str(
        "A booked round, from list_interviews. Narrows the prep to its format and its interviewers.",
      ),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.interviewBriefing(ctx.userId, {
        applicationId: s(args, "application_id"),
        interviewId: s(args, "interview_id"),
      }),
  },
  {
    name: "schedule_interview",
    title: "Put an interview round on the record",
    description:
      "Record a round of interviews — booked, or one that happened and was never written down. Reach for it the moment a date is agreed. Leave `round` out and it continues from the deepest round on file. Pass `prep_task_due_at` to put a prep task on their list in the same call; the result names it. Interviewers are contact ids from list_contacts — create_contact first for anybody new, because the person is worth keeping after this job. THREE THINGS IT DOES NOT DO: it does not move the application — check the stage that comes back and call move_application_stage if the board still says applied; it writes nothing to the timeline — record_interview_outcome does that afterwards; and it never raises the application's round counter above what you give it, and that counter only ever goes up. The same calendar_event_id twice returns `duplicateOf` and writes nothing.",
    inputSchema: object(
      {
        application_id: str("Which job this round is for"),
        round: num(
          "Which round, counting from 1. Leave it out to continue from the deepest round already on file.",
        ),
        label: str("What it is called — 'Phone screen', 'Take-home', 'System design', 'Onsite loop'"),
        format: {
          type: "string",
          enum: [...INTERVIEW_FORMATS],
          description: "What kind of round it is. Defaults to VIDEO.",
        },
        scheduled_at: str(
          "When it is. YYYY-MM-DD, or a full ISO timestamp for a time. A bare date lands at 9am in their own zone.",
        ),
        duration_mins: num("How long it is booked for, in minutes"),
        location: str("Where, or the joining link — 'Zoom', a Meet URL, 'their office, 4th floor'"),
        prep: str("What to say, what to ask, what to avoid. Anything worth having written down before walking in."),
        interviewer_ids: strArray("Contact ids of the people in the room, from list_contacts"),
        calendar_event_id: str(
          "The provider's event id when this came off a calendar, so a second sweep does not queue it again",
        ),
        prep_task_due_at: str("Also add a prep task due on this day, YYYY-MM-DD. Omit for no task."),
        prep_task_title: str("What that task should say. Defaults to naming the round and the company."),
      },
      ["application_id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.scheduleInterview(ctx.userId, required(args, "application_id"), {
        ...interviewInputFrom(args),
        prepTaskDueAt: s(args, "prep_task_due_at"),
        prepTaskTitle: s(args, "prep_task_title"),
      }),
  },
  {
    name: "interviews_from_calendar",
    title: "Meetings that look like interviews",
    description:
      "Read this person's calendar for meetings with people on their pipeline and hand each back SHAPED AS AN INTERVIEW, ready for schedule_interview. It writes nothing: 'Acme — chat' might be a screen or a catch-up, and only the person can say. Each comes with the event id, title, start, duration, whether it looks like video or onsite (read off the location and link, not guessed), the job it is about, and any contact on file in the room. `alreadyScheduled` marks ones with a round already recorded; passing `calendar_event_id` through to schedule_interview keeps it safe even if you miss that. Needs a connected calendar, and says so when there is none. Read the list back, ask which are interviews, then call schedule_interview once per yes.",
    inputSchema: object({
      days: num("How far ahead to look. Default 21, most 90"),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
    handler: async (args, ctx) =>
      interviews.interviewsFromCalendar(ctx.userId, defined({ days: n(args, "days") })),
  },
  {
    name: "list_interviews",
    title: "List interview rounds",
    description:
      "Every round on file, newest first, with who was in the room, how it went and how many questions were recorded against it. Reach for it to answer 'what have I got coming up' (pass upcoming_only), 'how did the Stripe rounds go' (pass application_id or company_id), or before scheduling another one so the round number continues rather than restarting. Rounds on archived applications are never returned. For a week of dates across the whole search, list_schedule is better — it merges these with follow-ups, tasks and real calendar meetings. Read-only.",
    inputSchema: object({
      application_id: str("Only rounds for this job"),
      company_id: str("Only rounds at this employer, across every application there"),
      from: str("Only rounds scheduled on or after this day, YYYY-MM-DD"),
      to: str("Only rounds scheduled on or before this day, YYYY-MM-DD"),
      outcome: {
        type: "string",
        enum: [...INTERVIEW_OUTCOMES],
        description: "Only rounds in this state",
      },
      upcoming_only: bool("Only rounds still ahead of now"),
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await interviews.listInterviews(
          ctx.userId,
          defined({
            applicationId: s(args, "application_id"),
            companyId: s(args, "company_id"),
            from: s(args, "from"),
            to: s(args, "to"),
            outcome: s(args, "outcome") as never,
            upcomingOnly: b(args, "upcoming_only"),
            limit: n(args, "limit"),
          }),
        ),
        n(args, "limit"),
        100,
      ),
  },
  {
    name: "get_interview",
    title: "Get one interview round",
    description:
      "One round in full: the format, when it is, where, who is in the room, the prep written beforehand, the debrief written after, and every question recorded against it with the answer that was given. Read this before update_interview or record_interview_outcome, because both of those replace the fields you send.",
    inputSchema: object({ id: str("Interview id, from list_interviews") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => interviews.getInterview(ctx.userId, required(args, "id")),
  },
  {
    name: "update_interview",
    title: "Fix an interview round",
    description:
      "Correct a round that is already on file — the time moved, the format changed, somebody else joined the panel. Only the fields you send change, so this is safe to call with one of them. TWO TRAPS. `interviewer_ids` REPLACES the whole set rather than adding to it, so read the round with get_interview first and send everyone who should be on it. And this is for FIXING a round, not for saying how it went: record_interview_outcome is what writes the timeline entry and the questions. It can still repair a round on an application you have since archived, which is deliberate — the id could only have come from somewhere that already showed it to you.",
    inputSchema: object(
      {
        id: str("Interview id"),
        round: num("Which round, counting from 1"),
        label: str("What it is called"),
        format: { type: "string", enum: [...INTERVIEW_FORMATS], description: "What kind of round it is" },
        outcome: {
          type: "string",
          enum: [...INTERVIEW_OUTCOMES],
          description: "Where it got to. Prefer record_interview_outcome, which also writes the timeline.",
        },
        scheduled_at: str("When it is, YYYY-MM-DD or a full ISO timestamp"),
        duration_mins: num("How long, in minutes"),
        location: str("Where, or the joining link"),
        prep: str("What to say, what to ask, what to avoid. Replaces what is there."),
        interviewer_ids: strArray("REPLACES who is in the room. Send everyone, not just the new one."),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.updateInterview(ctx.userId, required(args, "id"), interviewInputFrom(args)),
  },
  {
    name: "record_interview_outcome",
    title: "Say how a round went, and what they asked",
    description:
      "Reach for this straight after an interview: it records how the round went, their debrief, and the questions they were asked with the answers they ACTUALLY gave. Ask for the real answer, not a better one — the bank's value is knowing which real answers led somewhere; the improved version goes in `better`. Two side effects, both intended: it writes the round's line on the timeline, REWRITING its own earlier line rather than adding a second, and it raises the application's round counter if this round is deeper — never lowering it. It does NOT move the stage: a rejection is still move_application_stage, with a loss reason so loss_report can use it. Confidence is 1 to 5 in their own judgement; leave it out rather than guessing.",
    inputSchema: object(
      {
        id: str("Interview id"),
        outcome: {
          type: "string",
          enum: [...INTERVIEW_OUTCOMES],
          description: "Where it got to. Defaults to HELD when the round was still scheduled.",
        },
        debrief: str("What they thought, in their own words. Replaces what is there."),
        occurred_at: str("When it actually happened, YYYY-MM-DD or ISO. Defaults to when it was scheduled."),
        questions: {
          type: "array",
          description:
            "What was asked, and what they said. Additive — nothing already on file is touched, so this is safe to call again as they remember more.",
          items: object(
            {
              question: str("What was asked, as close to the interviewer's own words as they remember"),
              kind: {
                type: "string",
                enum: [...QUESTION_KINDS],
                description:
                  "What sort of question. MINE is one they asked the interviewer. Defaults to BEHAVIOURAL.",
              },
              answer: str("What they ACTUALLY said. Not an improved version."),
              confidence: num("Their own verdict on the answer, 1 to 5. Leave it out rather than guessing."),
              better: str("What they wish they had said. This is where the improved version goes."),
            },
            ["question"],
          ),
        },
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.recordInterviewOutcome(ctx.userId, required(args, "id"), {
        outcome: s(args, "outcome") as never,
        debrief: s(args, "debrief"),
        occurredAt: s(args, "occurred_at"),
        questions: questionsFrom(args),
      }),
  },
  {
    name: "log_interview_questions",
    title: "Add questions to a round already on file",
    description:
      "Append what was asked to a round that is already recorded, without touching how it went. Reach for it when somebody remembers another question a day later, which is most of them. Additive: nothing already on file is changed or removed, so calling it twice adds twice — read the round with get_interview first if you are not sure whether a question is already there. Record the answer they actually gave rather than a better one; `better` is where the improved version goes.",
    inputSchema: object(
      {
        interview_id: str("Interview id"),
        questions: {
          type: "array",
          description: "The questions to add.",
          items: object(
            {
              question: str("What was asked"),
              kind: {
                type: "string",
                enum: [...QUESTION_KINDS],
                description: "What sort of question. MINE is one they asked the interviewer.",
              },
              answer: str("What they actually said"),
              confidence: num("Their own verdict, 1 to 5"),
              better: str("What they wish they had said"),
            },
            ["question"],
          ),
        },
      },
      ["interview_id", "questions"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => ({
      added: await interviews.addQuestions(
        ctx.userId,
        required(args, "interview_id"),
        questionsFrom(args) ?? [],
      ),
    }),
  },
  {
    name: "update_interview_question",
    title: "Correct one recorded question",
    description:
      "Fix a question, an answer, a rating or the better version. Only the fields you send change. Correcting the WORDS of a question re-files it in the bank, which is usually what you want — the bank groups by exact wording, so two rememberings of the same question sit apart until one is edited to match the other. Question ids come from get_interview or question_bank.",
    inputSchema: object(
      {
        id: str("Question id, from get_interview or question_bank"),
        question: str("What was asked. Changing this re-files it in the bank."),
        kind: { type: "string", enum: [...QUESTION_KINDS], description: "What sort of question" },
        answer: str("What they actually said"),
        confidence: num("Their own verdict, 1 to 5. 0 means nobody said."),
        better: str("What they wish they had said"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.updateQuestion(ctx.userId, required(args, "id"), {
        question: s(args, "question"),
        kind: s(args, "kind") as never,
        answer: s(args, "answer"),
        confidence: n(args, "confidence"),
        better: s(args, "better"),
      }),
  },
  {
    name: "delete_interview_question",
    title: "Remove a recorded question",
    description:
      "Take one question off a round for good — it was recorded twice, or it was not really a question. Gone, with no archive. Deleting the LAST answer on file for something also removes it from the bank, so a question asked at three employers loses a third of its evidence; prefer correcting it with update_interview_question where the problem is the wording.",
    inputSchema: object({ id: str("Question id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => interviews.deleteQuestion(ctx.userId, required(args, "id")),
  },
  {
    name: "delete_interview",
    title: "Remove an interview round",
    description:
      "Delete a round and everything recorded against it — every question, every answer, and the line it put on the timeline. There is no archive for interviews, so this is gone, and the questions are usually the part worth keeping. Say what will go, including how many questions, and get a plain yes first. The application's round counter is deliberately NOT lowered: how far something got is a fact, and the funnel is built out of that column.",
    inputSchema: object({ id: str("Interview id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => interviews.deleteInterview(ctx.userId, required(args, "id")),
  },
  {
    name: "question_bank",
    title: "What keeps getting asked, and which answers worked",
    description:
      "Every interview question ever recorded, grouped and ranked — what turns interview notes into preparation. `repeated` is what to rehearse: asked more than once, with each answer and employer. `weak` is the homework: no answer recorded, or rated badly. `worked` is answers from a round that passed or a job that reached an offer. `coverage` is per kind of question and INCLUDES zeros — never having recorded a system design question is the finding. THREE TRAPS, also in `caveats`: questions group by EXACT WORDING, so one question remembered two ways is two entries — merge them by eye before reporting a count, and use update_interview_question to make them match; `ledSomewhere` is correlation only, not proof the answer was why; and under about twenty questions there is too little to conclude anything. Rounds on archived applications are excluded. Read-only, saves nothing.",
    inputSchema: object({
      application_id: str("Only questions from rounds on this job"),
      company_id: str("Only questions this employer asked"),
      kind: { type: "string", enum: [...QUESTION_KINDS], description: "Only this sort of question" },
      search: str("Only questions whose words contain this"),
      min_times_asked: num("Only questions asked at least this many times. Default 1."),
      limit: num("How many distinct questions to return. Default 100, ceiling 300."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      interviews.questionBank(
        ctx.userId,
        defined({
          applicationId: s(args, "application_id"),
          companyId: s(args, "company_id"),
          kind: s(args, "kind") as never,
          search: s(args, "search"),
          minTimesAsked: n(args, "min_times_asked"),
          limit: n(args, "limit"),
        }),
      ),
  },
  {
    name: "list_stage_cadences",
    title: "How long after a move a follow-up is armed for",
    description:
      "The chase clock, per stage. Every time an application moves, its follow-up date is set a number of days out — seven after applying, four while interviewing, two on an offer — and this says what those numbers are for this person and whether they set them or inherited them. Reach for it before set_stage_cadence so you can say what is changing, and when somebody asks why a follow-up appeared on a date they did not pick. `source` is the field to read: 'default' means no row exists and the built-in is being used, 'yours' means they set that number, and 'off' means they deliberately turned that stage's chasing off, which is not the same thing as never having said. ACCEPTED and LOST are absent on purpose — a terminal move clears the follow-up date outright, so a cadence on one could never fire. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => stageCadence.listStageCadences(ctx.userId),
  },
  {
    name: "set_stage_cadence",
    title: "Change how soon a stage chases",
    description:
      "Set how many days after landing in a stage the follow-up date is armed for. Reach for it when somebody says they chase too soon, too late, or not at all — 'stop nagging me about wishlist rows' is `stage: WISHLIST, days: null`; 'give applications a fortnight' is `stage: APPLIED, days: 14`. THREE MEANINGS FOR ONE ARGUMENT: a NUMBER stores that many days; `days: null` stores 'arm nothing for this stage'; LEAVING days OUT deletes their setting and returns to the built-in. Say which you are doing before you do it. It changes future moves only — no date already on the board moves (that is update_application), and a date set by hand is never overwritten by a move. ACCEPTED and LOST are refused: a terminal move clears the date outright.",
    inputSchema: object(
      {
        stage: {
          type: "string",
          enum: ["WISHLIST", "APPLIED", "INTERVIEWING", "OFFER"],
          description: "Which stage's clock to change",
        },
        days: num(
          "Days from the move to the follow-up, 0 to 365. Send null to arm nothing for this stage. Omit it entirely to delete the setting and go back to the built-in.",
        ),
        revert: bool("Delete the setting and go back to the built-in. The same as omitting days."),
      },
      ["stage"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const stage = required(args, "stage") as Stage;
      // `null` and "absent" mean different things here, and `n()` collapses
      // them, so the raw argument is read rather than the helper's answer.
      const raw = args.days;
      const days =
        b(args, "revert") || raw === undefined
          ? undefined
          : raw === null
            ? null
            : typeof raw === "number"
              ? raw
              : undefined;
      return stageCadence.setStageCadence(ctx.userId, stage, days);
    },
  },
  {
    name: "list_referrals",
    title: "Who vouched for you where",
    description:
      "Every referral on file, where it stands and whether it came to anything. Reach for it when somebody asks who has put them forward, who they are waiting on, or who they owe a thank-you; reach for list_relationships instead for who is worth talking to at all — this covers only people who actually asked on their behalf. `converted` is derived from the application's own stage and timeline, so it cannot disagree with the funnel. `thanksOwed` is the list that matters: converted, never thanked. `waitingDays` counts from the last STATUS change, not the last edit. `byStatus` counts the whole set, whatever the filters, with what each state means for the next step. For a weekly review call it twice: thanks_owed true first (one message each), then waiting_for_days 10 (a nudge is one line, not a second ask). Referrals from an archived person are excluded; one whose application was archived stays. Read-only.",
    inputSchema: object({
      contact_id: str("Only referrals from this person"),
      application_id: str("Only referrals for this job"),
      company_id: str("Only referrals at this employer"),
      status: {
        type: "string",
        enum: [...REFERRAL_STATUSES],
        description: "Only referrals in this state",
      },
      thanks_owed: bool("Only the ones that converted and were never acknowledged"),
      waiting_for_days: num("Only ones still ASKED or AGREED and older than this many days"),
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const [rows, byStatus] = await Promise.all([
        referrals.listReferrals(
          ctx.userId,
          defined({
            contactId: s(args, "contact_id"),
            applicationId: s(args, "application_id"),
            companyId: s(args, "company_id"),
            status: s(args, "status") as never,
            thanksOwed: b(args, "thanks_owed"),
            waitingForDays: n(args, "waiting_for_days"),
            limit: n(args, "limit"),
          }),
        ),
        referrals.referralCounts(ctx.userId),
      ]);
      const max = Math.min(Math.max(Math.trunc(n(args, "limit") ?? 100), 1), LIST_CEILING);
      const body = { referrals: rows.slice(0, max), byStatus };
      return rows.length > max ? withNotice(body, truncationNotice(max, rows.length)) : body;
    },
  },
  {
    name: "record_referral",
    title: "Record that somebody put their name behind you",
    description:
      "Log a referral — you asked somebody to refer you, or they offered. Reach for it the moment the ask is made, not when it lands: 'waiting on three people' is only a question this app can answer if the asks are on file. The person is a contact id from list_contacts; create_contact them first if they are not on file yet, because somebody who refers you is worth keeping long after this job. The application is optional and often comes later — 'do you know anyone at Stripe' is asked before anybody applies — and passing one fills in the employer for you. Status starts at ASKED. When it converts, a thank-you task appears on their list automatically, once, the first time the application reaches interviewing or beyond; that is the point of recording it.",
    inputSchema: object(
      {
        contact_id: str("Who vouched for you, from list_contacts"),
        application_id: str("The job, if there is one yet"),
        company_id: str("The employer. Filled in from the application when you pass one."),
        status: {
          type: "string",
          enum: [...REFERRAL_STATUSES],
          description: "Where it stands. Defaults to ASKED.",
        },
        asked_on: str("When you asked, YYYY-MM-DD. Defaults to today."),
        notes: str("What you asked for and what they said, in your own words"),
      },
      ["contact_id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      referrals.createReferral(ctx.userId, {
        contactId: required(args, "contact_id"),
        applicationId: s(args, "application_id"),
        companyId: s(args, "company_id"),
        status: s(args, "status") as never,
        askedOn: s(args, "asked_on"),
        notes: s(args, "notes"),
      }),
  },
  {
    name: "update_referral",
    title: "Move a referral along, or mark it thanked",
    description:
      "Change where a referral stands, attach the application once there is one, or record that you have thanked them. Only the fields you send change. Moving the STATUS restarts the waiting clock, which is what 'you asked three weeks ago' counts from — so move it when something actually happened, and edit the notes when nothing did. `thanked: true` stamps today and takes it off the thanks-owed list; that is the field the whole model exists for, so do not set it because a thank-you was drafted, only because one was sent.",
    inputSchema: object(
      {
        id: str("Referral id, from list_referrals"),
        status: {
          type: "string",
          enum: [...REFERRAL_STATUSES],
          description: "Where it stands now. Moving this restarts the waiting clock.",
        },
        application_id: str("Attach the job, now that there is one. An empty string detaches it."),
        company_id: str("The employer. An empty string detaches it."),
        contact_id: str("Correct who vouched"),
        asked_on: str("When you asked, YYYY-MM-DD"),
        notes: str("Replaces the notes"),
        thanked: bool("True stamps today as when you thanked them; false clears it"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      referrals.updateReferral(ctx.userId, required(args, "id"), {
        contactId: s(args, "contact_id"),
        applicationId: s(args, "application_id"),
        companyId: s(args, "company_id"),
        status: s(args, "status") as never,
        askedOn: s(args, "asked_on"),
        notes: s(args, "notes"),
        thanked: b(args, "thanked"),
      }),
  },
  {
    name: "delete_referral",
    title: "Remove a referral",
    description:
      "Delete a referral for good. There is no archive for these. What is lost is the record that a named person put their reputation behind you, which is usually worth keeping even when the job went nowhere — prefer update_referral with a status of DECLINED or NO_ANSWER. Say what will go and get a plain yes first.",
    inputSchema: object({ id: str("Referral id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => referrals.deleteReferral(ctx.userId, required(args, "id")),
  },
  {
    name: "list_stage_templates",
    title: "List the stage checklists",
    description:
      "What this person has said should happen when a job reaches a stage. Each line carries the stage that fires it, the task title and detail, dueInDays (days from the move to the due date; null means the task gets no date), whether it is enabled, and `firedFor` — how many applications it has already made a task on. Read this before offering to add one, and before answering 'why did that task appear'. An empty list is normal and means nothing fires; seed_stage_templates offers a starting set. Read-only.",
    inputSchema: object({
      stage: { type: "string", enum: STAGE_VALUES, description: "Only the lines that fire on this stage" },
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const [rows, usage] = await Promise.all([
        stageTemplates.listStageTemplates(ctx.userId, enumArg(args, "stage", STAGE_VALUES) as Stage | undefined),
        stageTemplates.stageTemplateUsage(ctx.userId),
      ]);
      return rows.map((row) => ({ ...row, firedFor: usage.get(row.id) ?? 0 }));
    },
  },
  {
    name: "create_stage_template",
    title: "Add a line to a stage checklist",
    description:
      "Say that reaching a stage should put a task on their list — 'send a thank-you' the day after every interview, 'check the posting is still up' a week after applying. The line fires ONCE PER APPLICATION, ever: going back a stage and forward again does not re-add what they already ticked off. It fires on future moves only and never retroactively, so adding a line does not put a task on forty existing jobs. due_in_days is counted from the move (0 is that day, 7 is a week later); leave it out and the task gets no due date at all, which is right for 'eventually'. Keep the title short and in the second person — it lands on a list beside things they wrote themselves.",
    inputSchema: object(
      {
        stage: { type: "string", enum: STAGE_VALUES, description: "The stage a job has to reach for this to fire" },
        title: str("The task, e.g. 'Send a thank-you'"),
        detail: str("A line of context shown under it"),
        due_in_days: num("Days from the move to the due date. 0 is that day. Omit for no due date"),
      },
      ["stage", "title"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      stageTemplates.createStageTemplate(ctx.userId, {
        stage: required(args, "stage") as Stage,
        title: required(args, "title"),
        detail: s(args, "detail"),
        dueInDays: n(args, "due_in_days"),
      }),
  },
  {
    name: "update_stage_template",
    title: "Change a stage checklist line",
    description:
      "Edit one line — reword it, move it to a different stage, change when it comes due, or switch it off without deleting it. Only the fields you send change. This NEVER touches tasks the line has already made: those are on somebody's list already, and a setting should not rewrite work in progress.",
    inputSchema: object(
      {
        id: str("Checklist line id, from list_stage_templates"),
        stage: { type: "string", enum: STAGE_VALUES, description: "Fire on this stage instead" },
        title: str("The task"),
        detail: str("A line of context shown under it"),
        due_in_days: num("Days from the move to the due date"),
        enabled: bool("False parks the line without deleting it"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      stageTemplates.updateStageTemplate(ctx.userId, required(args, "id"), {
        stage: enumArg(args, "stage", STAGE_VALUES) as Stage | undefined,
        title: s(args, "title"),
        detail: s(args, "detail"),
        dueInDays: n(args, "due_in_days"),
        enabled: b(args, "enabled"),
      }),
  },
  {
    name: "delete_stage_template",
    title: "Remove a stage checklist line",
    description:
      "Delete one line for good. The tasks it has already made STAY on the list — deleting a setting must not delete work somebody is part-way through. If the aim is to stop it firing for a while, update_stage_template with enabled false is the reversible version and is usually what they mean.",
    inputSchema: object({ id: str("Checklist line id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => stageTemplates.deleteStageTemplate(ctx.userId, required(args, "id")),
  },
  {
    name: "seed_stage_templates",
    title: "Offer a starting set of stage checklists",
    description:
      "Fill in a short, boring starting set of checklist lines — the handful of things that actually get forgotten: check the posting is still up a week after applying, find someone who works there, send a thank-you the day after an interview, write down what they asked, record the offer numbers, ask a rejection what would have made the difference. Skips any stage that already has lines rather than adding duplicates, and reports how many it created and how many it skipped. Offer this once, to somebody who does not want to design their own; do not call it unprompted on an account that already has a checklist.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => stageTemplates.seedStageTemplates(ctx.userId),
  },
  {
    name: "propose_changes",
    title: "Queue changes for them to approve",
    description:
      "Queue suggested changes on the dashboard for the person to accept or dismiss later, instead of asking about each now. Use it at the end of inbox_review, or whenever you have read something and found several things the pipeline does not know — they are rarely around when you finish. Each proposal needs a plain `summary`, the `evidence` behind it (quote the line from the email or calendar entry — a proposal nobody can check should not be accepted), and a `payload` holding the arguments of the single call it becomes. Six kinds: LOG_ACTIVITY (applicationId or contactId, type, body, occurredAt), MOVE_STAGE (applicationId, stage, note, interviewRound, lossReasons), CREATE_TASK (title, detail, dueAt, applicationId), SET_FOLLOW_UP (applicationId, nextFollowUpAt), CREATE_CONTACT (name, email, title, relationship, applicationId), CREATE_APPLICATION (company, roleTitle, jobUrl, jobDescription, location, workMode, salaryRange — the posting already read, never a URL to fetch later; it lands on the wishlist). NOTHING IS WRITTEN until they accept. A malformed proposal is refused alone and the rest are kept — read `refused` back rather than assuming everything queued.",
    inputSchema: object(
      {
        proposals: {
          type: "array",
          description: "The changes to queue, in the order you want them read",
          items: object(
            {
              kind: { type: "string", enum: [...PROPOSAL_KINDS], description: "Which of the five acts this is" },
              summary: str("One line, in their words: 'Log the recruiter screen on the 3rd', 'Move Northwind to Interviewing'"),
              evidence: str("The line that produced it. Quote it rather than paraphrasing"),
              source: str("Where it came from: 'inbox_review', 'calendar', 'their message'"),
              application_id: str("The job this is about, for grouping the queue"),
              contact_id: str("The person this is about, for grouping the queue"),
              payload: {
                type: "object",
                description: "The arguments of the call this becomes. Keys as named in the description above",
                additionalProperties: true,
              },
            },
            ["kind", "summary", "payload"],
          ),
        },
      },
      ["proposals"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const raw = Array.isArray(args.proposals) ? (args.proposals as Json[]) : [];
      return proposals.proposeChanges(
        ctx.userId,
        raw.map((item) => ({
          kind: required(item, "kind") as (typeof PROPOSAL_KINDS)[number],
          summary: required(item, "summary"),
          evidence: s(item, "evidence"),
          source: s(item, "source"),
          applicationId: s(item, "application_id") ?? null,
          contactId: s(item, "contact_id") ?? null,
          payload: (item.payload ?? {}) as Record<string, unknown>,
        })),
      );
    },
  },
  {
    name: "list_proposals",
    title: "What is waiting for a yes",
    description:
      "The changes queued for this person to approve, oldest first, each with its summary, evidence, the job or person it is about, and the payload it would apply. Call this before proposing anything so you do not queue the same suggestion twice, and when they ask 'what have you got for me'. Defaults to the pending ones; pass status to look at what they accepted or dismissed — a dismissed proposal is an answer, and proposing it again is how an assistant becomes annoying. Read-only.",
    inputSchema: object({
      status: { type: "string", enum: [...PROPOSAL_STATUSES], description: "PENDING (default), ACCEPTED or DISMISSED" },
      application_id: str("Only the ones about this job"),
      limit: limitArg(50),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await proposals.listProposals(ctx.userId, {
          status: enumArg(args, "status", PROPOSAL_STATUSES) as ProposalStatus | undefined,
          applicationId: s(args, "application_id"),
        }),
        n(args, "limit"),
        50,
      ),
  },
  {
    name: "accept_proposal",
    title: "Apply a queued change",
    description:
      "Do what a queued proposal says — log the activity, move the stage, add the task, whatever it was. THIS WRITES. Only call it when the person has said yes to that specific proposal; queueing something and then accepting it yourself defeats the point of a queue. Returns what it did. If the world moved underneath it — the job was deleted, the stage is not a stage — nothing is written, the proposal goes back to waiting with the reason on it, and the error says why.",
    inputSchema: object({ id: str("Proposal id, from list_proposals") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => proposals.acceptProposal(ctx.userId, required(args, "id")),
  },
  {
    name: "dismiss_proposal",
    title: "Say no to a queued change",
    description:
      "Take a proposal off the queue without doing it. Nothing is written and nothing is deleted — the row stays as DISMISSED, which is how list_proposals can tell you later that this was already declined. Pass all true to clear everything still waiting, which is the 'I will do this myself' answer.",
    inputSchema: object({
      id: str("Proposal id. Omit when passing all"),
      all: bool("Dismiss everything still waiting"),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      b(args, "all")
        ? proposals.dismissAllProposals(ctx.userId)
        : proposals.dismissProposal(ctx.userId, required(args, "id")),
  },
  {
    name: "list_tags",
    title: "List the tags on file",
    description:
      "Every label this person owns, as records rather than free strings: id, kind, name, colour, and how many applications, companies and contacts wear each. Six kinds, and they are separate lists that never collide — APPLICATION (where a job came from: LinkedIn, a referral, cold outreach), COMPANY, CONTACT (how a person is filed: recruiter, ex-colleague), INDUSTRY, SIZE and LOCATION (the three that used to be free-text fields on a company). Call this before writing tags anywhere: passing an existing id is exact, and passing a name that already exists matches it case-insensitively rather than creating a twin. Read-only.",
    inputSchema: object({
      kind: { type: "string", enum: [...TAG_KINDS], description: "Only this kind. Omit for all of them." },
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => tags.listTags(ctx.userId, enumArg(args, "kind", TAG_KINDS) as TagKind | undefined),
  },
  {
    name: "create_tag",
    title: "Create a tag",
    description:
      "Add a label of one kind. Usually unnecessary — writing a name that does not exist on an application, company or contact creates it — so reach for this when someone is setting up their vocabulary deliberately, or wants a specific colour. Colours are palette names, not hex: slate, blue, teal, green, amber, red, violet or pink. Refuses a name that already exists in that kind, case-insensitively, and says which one it clashed with.",
    inputSchema: object(
      {
        kind: { type: "string", enum: [...TAG_KINDS], description: "What this labels" },
        name: str("What it is called, e.g. 'Fintech', 'Referral', 'Remote'"),
        color: { type: "string", enum: [...TAG_COLORS], description: "Palette name. Default slate." },
      },
      ["kind", "name"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      tags.createTag(ctx.userId, {
        kind: required(args, "kind") as TagKind,
        name: required(args, "name"),
        ...defined({ color: s(args, "color") }),
      }),
  },
  {
    name: "update_tag",
    title: "Rename or recolour a tag",
    description:
      "Change a tag's name or colour in one place, and everything wearing it follows — which is the whole reason these are rows rather than strings. Renaming 'Fintech' to 'Financial services' updates every company at once. The kind cannot be changed: a location is not an industry, and moving one would silently reclassify everything wearing it.",
    inputSchema: object(
      {
        id: str("Tag id from list_tags"),
        name: str("New name"),
        color: { type: "string", enum: [...TAG_COLORS], description: "New palette colour" },
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      tags.updateTag(ctx.userId, required(args, "id"), {
        ...defined({ name: s(args, "name"), color: s(args, "color") }),
      }),
  },
  {
    name: "delete_tag",
    title: "Delete a tag",
    description:
      "Remove a label for good. It comes off everything that wore it and nothing else changes — no application, company or contact is deleted, they simply stop carrying that label. Returns how many things it was taken off, which is worth reporting back before someone assumes it was unused. This is the tool for tidying up a list that has filled with near-duplicates.",
    inputSchema: object({ id: str("Tag id from list_tags") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => tags.deleteTag(ctx.userId, required(args, "id")),
  },
  {
    name: "seed_tags",
    title: "Offer the starter tags for a kind",
    description:
      "Create the handful of obvious labels for one kind, skipping any the person already has: the usual channels for APPLICATION, the usual relationships for CONTACT, and a set of headcount bands for SIZE. Nothing is imposed — these become ordinary rows they can rename, recolour or delete. Returns the whole list for that kind afterwards. There is nothing sensible to seed for COMPANY, INDUSTRY or LOCATION, and asking for those returns what is already there.",
    inputSchema: object(
      { kind: { type: "string", enum: [...TAG_KINDS], description: "Which list to seed" } },
      ["kind"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => tags.seedTags(ctx.userId, required(args, "kind") as TagKind),
  },
  {
    name: "create_application",
    title: "Create an application",
    description:
      "Track a new job. Paste the full posting into jobDescription — it is what you will tailor the resume against later. The company is created automatically if it does not exist. Pass companyWebsite when you know it — it is what makes the company's logo appear in the pipeline, and it costs nothing to include. A job link and description are OPTIONAL: an application that started as a LinkedIn message with no listing is still an application — track it with just company and roleTitle, put 'Cold outreach' in tags, and attach the person messaged with create_contact.",
    inputSchema: object(
      {
        company: str("Company name"),
        companyWebsite: str("The company's own site, e.g. stripe.com. Shows their logo in the pipeline."),
        roleTitle: str("Job title"),
        stage: { type: "string", enum: STAGE_VALUES, description: "Starting stage. Default WISHLIST." },
        interviewRound: num(
          "Which round of interviews it is on, counting from 1. Only meaningful while INTERVIEWING; 0 means nobody has said, which is fine. It is never cleared when an application ends — a rejection after four rounds is a fact about how far it got, and the funnel is built out of exactly this.",
        ),
        roundLabel: str("What that round is called — 'Phone screen', 'Take-home', 'Onsite'. Optional."),
        lossReasons: strArray(
          "Why it ended, by name — 'Rejected', 'Ghosted', 'Withdrew', 'Declined their offer', 'Role closed', or their own words. Only read when the stage is LOST. Matched against the reasons they already use and created when nothing matches.",
        ),
        jobUrl: str("Link to the posting"),
        jobDescription: str("The full job posting text"),
        location: str("Job location. Free text — call list_field_values first and reuse a spelling already in use."),
        workMode: str("Remote, Hybrid, On-site, or however they say it. Free text: list_field_values has the ones already in use."),
        salaryRange: str("Advertised or expected compensation"),
        tagIds: strArray("Tag ids from list_tags, kind APPLICATION. Exact; wins over tags."),
        tags: strArray(
          "How to file it, by NAME, and several at once is normal: ['LinkedIn', 'Referral'] for a posting a friend also flagged. Matched case-insensitively against the tags that exist and created only when nothing matches, so call list_tags first.",
        ),
        notes: str("Any notes"),
        appliedAt: str("ISO date they applied"),
        nextFollowUpAt: str("ISO date to follow up. Auto-set from the stage if omitted."),
        resumeId: str("Id of the resume used"),
      },
      ["company", "roleTitle"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.createApplication(ctx.userId, {
        company: required(args, "company"),
        roleTitle: required(args, "roleTitle"),
        ...defined({
          companyWebsite: s(args, "companyWebsite"),
          stage: s(args, "stage") as Stage | undefined,
          interviewRound: n(args, "interviewRound"),
          roundLabel: s(args, "roundLabel"),
          lossReasons: a(args, "lossReasons"),
          jobUrl: s(args, "jobUrl"),
          jobDescription: s(args, "jobDescription"),
          location: s(args, "location"),
          workMode: s(args, "workMode"),
          salaryRange: s(args, "salaryRange"),
          tagIds: a(args, "tagIds"),
          tags: a(args, "tags"),
          sources: a(args, "sources"),
          source: s(args, "source"),
          notes: s(args, "notes"),
          appliedAt: s(args, "appliedAt"),
          nextFollowUpAt: s(args, "nextFollowUpAt"),
          resumeId: s(args, "resumeId"),
        }),
      }),
  },
  {
    name: "update_application",
    title: "Update an application",
    description:
      "Update fields on an application. Changing `stage` here also writes a timeline entry and resets the follow-up date. `tags` and `lossReasons` each REPLACE the whole list — read the current one from get_application, add or remove, and pass the full list back. The two are separate sets on the same application: writing one never disturbs the other.",
    inputSchema: object(
      {
        id: str("Application id"),
        company: str("Company name"),
        companyWebsite: str("The company's own site, e.g. stripe.com. Shows their logo in the pipeline."),
        roleTitle: str("Job title"),
        stage: { type: "string", enum: STAGE_VALUES, description: "New stage" },
        interviewRound: num(
          "Which round of interviews it is on, counting from 1. Only meaningful while INTERVIEWING; 0 means nobody has said, which is fine. It is never cleared when an application ends — a rejection after four rounds is a fact about how far it got, and the funnel is built out of exactly this.",
        ),
        roundLabel: str("What that round is called — 'Phone screen', 'Take-home', 'Onsite'. Optional."),
        lossReasons: strArray(
          "Why it ended, by name. REPLACES the whole set — read the current one from get_application first. Only read when the stage is LOST.",
        ),
        lossTagIds: strArray("The same by id, from list_tags kind LOSS. Exact; wins over lossReasons."),
        jobUrl: str("Posting link"),
        jobDescription: str("Job posting text"),
        location: str("Location. Free text — list_field_values first, and an empty string clears it."),
        workMode: str("Work mode. Free text — list_field_values first, and an empty string clears it."),
        salaryRange: str("Compensation"),
        tagIds: strArray("Tag ids. Exact; wins over tags. REPLACES the whole set."),
        tags: strArray("Tag names — REPLACES the whole set, matched or created as above"),
        notes: str("Notes"),
        appliedAt: str("ISO date applied"),
        nextFollowUpAt: str("ISO date of next follow-up, or empty string to clear"),
        resumeId: str("Attach this resume id, or empty string to detach"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.updateApplication(ctx.userId, required(args, "id"), {
        ...defined({
          company: s(args, "company"),
          companyWebsite: s(args, "companyWebsite"),
          roleTitle: s(args, "roleTitle"),
          stage: s(args, "stage") as Stage | undefined,
          interviewRound: n(args, "interviewRound"),
          roundLabel: s(args, "roundLabel"),
          lossTagIds: a(args, "lossTagIds"),
          lossReasons: a(args, "lossReasons"),
          jobUrl: s(args, "jobUrl"),
          jobDescription: s(args, "jobDescription"),
          location: s(args, "location"),
          workMode: s(args, "workMode"),
          salaryRange: s(args, "salaryRange"),
          tagIds: a(args, "tagIds"),
          tags: a(args, "tags"),
          sources: a(args, "sources"),
          source: s(args, "source"),
          notes: s(args, "notes"),
          appliedAt: s(args, "appliedAt"),
          nextFollowUpAt: s(args, "nextFollowUpAt"),
          resumeId: s(args, "resumeId"),
        }),
      }),
  },
  {
    name: "move_application_stage",
    title: "Move an application to a new stage",
    description:
      "Advance or close an application — or several: pass `ids` instead of `id` for 'close out everything I never heard back from', and each moves exactly as if moved alone, with its own timeline entry and follow-up; ids not theirs or gone are skipped and listed rather than failing the batch, and `addedTasks` becomes a count. Logs the change to the timeline and schedules the next follow-up. Six stages: WISHLIST (not applied yet), APPLIED, INTERVIEWING (every conversation from recruiter screen to final round), OFFER, ACCEPTED (signed) and LOST (every other ending). Moving to INTERVIEWING sets round 1 unless you pass `interviewRound` — 'second interview' is 2. Moving to LOST, pass `lossReasons` with WHY: 'Rejected', 'Ghosted' (nobody ever replied — the common one), 'Withdrew', 'Declined their offer', 'Role closed', or anything that fits; names match what they already use or are created. The funnel reads them to tell a decision against them from silence, so ask rather than guess. Returns `addedTasks`: what the stage's checklist (list_stage_templates) put on their list — read those back.",
    inputSchema: object(
      {
        id: str("Application id"),
        ids: strArray("Several application ids, all moving to the same stage. Use instead of id."),
        stage: { type: "string", enum: STAGE_VALUES, description: "The new stage" },
        note: str("Optional note for the timeline entry — on every one, when moving several"),
        interviewRound: num(
          "Which round it reached, counting from 1. Recorded whatever the stage, because 'rejected after round 3' is how the funnel knows where it fell out; moving to INTERVIEWING with none on file yet sets round 1.",
        ),
        roundLabel: str("What that round is called — 'Phone screen', 'Take-home', 'Onsite'. Optional."),
        lossReasons: {
          type: "array",
          items: { type: "string" },
          description:
            "Why it ended, by name. REPLACES any reasons already on it, and an empty list clears them, whatever the stage.",
        },
      },
      ["stage"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const extra = defined({
        interviewRound: n(args, "interviewRound"),
        roundLabel: s(args, "roundLabel"),
        lossReasons: a(args, "lossReasons"),
      });
      const ids = a(args, "ids");
      if (ids?.length) {
        return pipeline.moveApplicationsStage(
          ctx.userId,
          ids,
          required(args, "stage") as Stage,
          extra,
          s(args, "note"),
        );
      }
      return pipeline.moveApplicationStage(
        ctx.userId,
        required(args, "id"),
        required(args, "stage") as Stage,
        s(args, "note"),
        extra,
      );
    },
  },
  {
    name: "log_activity",
    title: "Log activity on an application or a contact",
    description:
      "Append to a timeline. Pass applicationId for things that happened on an application — a recruiter call about the role, an interview, a note to self. Pass contactId for things that happened with a PERSON — a coffee, a call, a reply — and it becomes their history: the contact's page shows it and their 'last touched' date moves. Exactly one of the two, never both. When someone mentions talking to a person they know, this with contactId is how it gets remembered. Type OUTREACH is for messages the user sent first — a LinkedIn DM to a hiring manager, a cold email — which is how many applications actually start.",
    inputSchema: object(
      {
        applicationId: str("Application id — for events on an application"),
        contactId: str("Contact id — for events with a person"),
        type: { type: "string", enum: ACTIVITY_VALUES, description: "Kind of activity. Default NOTE." },
        body: str("What happened"),
        occurredAt: str("ISO datetime it happened. Defaults to now."),
      },
      ["body"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.addActivity(ctx.userId, {
        applicationId: s(args, "applicationId"),
        contactId: s(args, "contactId"),
        body: required(args, "body"),
        type: s(args, "type") as ActivityType | undefined,
        occurredAt: s(args, "occurredAt"),
      }),
  },
  {
    name: "list_follow_ups",
    title: "List follow-ups that are due",
    description:
      "The 'what has come round' tool, and the same thing the bell in the app counts. Returns four lists: applications whose follow-up date has arrived or passed, contacts whose ping date has — the people you meant to get back in touch with — tasks that are due or already late, and offers whose respond-by date has come round, which are the one date here that cannot be snoozed. All four are debts with a date on them; plan a day from the set. Each task carries `overdue`, which separates late from due today. Reach for list_tasks when the date does not matter and list_schedule when the question is about a window of time rather than about what is owed now.",
    inputSchema: object({
      withinDays: num("Look ahead this many days. 0 = due now, 7 = due within a week."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const withinDays = n(args, "withinDays") ?? 0;
      // The rich rows for the two the caller usually acts on, and the bell's
      // own dueNow for tasks and offer deadlines — the schedule's, not the
      // pipeline's, which knows nothing of offers. An offer due today was on
      // the bell and first in the brief, and missing here.
      const [applications, contacts, due] = await Promise.all([
        pipeline.followUpsDue(ctx.userId, withinDays),
        pipeline.contactFollowUpsDue(ctx.userId, withinDays),
        schedule.dueNow(ctx.userId, withinDays),
      ]);
      return { applications, contacts, tasks: due.tasks, offers: due.offers, total: due.total };
    },
  },
  {
    name: "snooze_follow_up",
    title: "Push a follow-up out",
    description:
      "Move a follow-up date further out without recording anything — for a thread you have decided to leave alone a while longer. Pass an applicationId or a contactId, and days from today; the date lands at 9am so it reads as due the morning you meant to do it. This is deferral, not progress: nothing is logged and the stage does not move, so use log_follow_up instead when you actually chased it.",
    inputSchema: object({
      applicationId: str("The application whose follow-up moves"),
      contactId: str("Or the person whose ping moves"),
      days: num("How many days from today. 3 = the day after tomorrow but one."),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const applicationId = s(args, "applicationId");
      const contactId = s(args, "contactId");
      if (Boolean(applicationId) === Boolean(contactId)) {
        throw new Error("Snooze exactly one thing: an application or a contact.");
      }
      const days = n(args, "days") ?? 3;
      return applicationId
        ? pipeline.snoozeFollowUp(ctx.userId, applicationId, days)
        : pipeline.snoozeContactFollowUp(ctx.userId, contactId as string, days);
    },
  },
  {
    name: "log_follow_up",
    title: "Record that you chased it",
    description:
      "You actually followed up: writes the touch on the timeline and moves the follow-up date out in one call. Use this rather than snooze_follow_up whenever something was said — a chase list emptied by snoozing looks exactly like one that was worked, and the timeline that answers 'when did I last talk to them' stays empty. Pass an applicationId or a contactId, what you said in body, and days for when to come back (a week if you leave it). Logging resets the quiet clock that list_applications reports as quietDays; it deliberately does NOT move the stage, because following up is not progress — use move_application_stage when something actually changed.",
    inputSchema: object({
      applicationId: str("The application you chased"),
      contactId: str("Or the person you pinged"),
      body: str("What you said or heard. Defaults to a plain 'Followed up.'"),
      type: { type: "string", enum: ACTIVITY_VALUES, description: "Kind of touch. Defaults to FOLLOW_UP." },
      days: num("When to come back to it, in days from today. Default 7."),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.logFollowUp(ctx.userId, {
        ...defined({
          applicationId: s(args, "applicationId"),
          contactId: s(args, "contactId"),
          body: s(args, "body"),
          type: s(args, "type") as ActivityType | undefined,
          days: n(args, "days"),
        }),
      }),
  },
  {
    name: "list_relationships",
    title: "Who has been worth something, and who has gone quiet",
    description:
      "diagnose_search for people: ranks everyone in the CRM by what they have been worth to the search, and how long since anything was logged. Reach for it when somebody asks who to thank, who to nudge or where their referrals really come from — and before a week of cold applications, since the people on file usually outperform them. Two counts, kept apart: `direct` is applications they are attached to (a referrer, the recruiter on that thread); `atCompany` is applications at a company they represent, much weaker evidence — never add the two together. Credit is for the furthest an application got; a wishlist row earns none. `worthKeepingWarm` is the list to act on: people with an interview, an offer or an agreed referral behind them who have gone quiet or have a ping due, ordered by what the silence has cost. Archived people and applications are excluded. `confident` false means too little is on file to rank anybody — say so rather than reading the order as meaningful. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => pipeline.listRelationships(ctx.userId),
  },
  {
    name: "diagnose_search",
    title: "Diagnose the job search",
    description:
      "Works out what is going wrong with the search rather than reporting counts. Returns a one-sentence verdict naming the funnel step that is losing people — no responses at all is a resume or targeting problem, interviews that do not convert is another — plus per-step conversion, median days in each stage, weekly volume for six weeks, applications gone quiet, and the response rate of each source, i.e. which channel works. Per resume, use resume_performance. Reach for this before giving any advice about a search: it is the difference between 'send more' and 'stop sending, the resume is the problem'. Every rate is null under five applications — say the counts, not a percentage. An application with two source tags counts in both, so those rows do not sum to the total. Progress is the furthest an application ever got, its interviewRound included. Says so plainly when there is not enough data yet. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => pipeline.diagnoseSearch(ctx.userId),
  },
  {
    name: "get_funnel",
    title: "Get the funnel, rung by rung",
    description:
      "The numbers behind the analytics page's flow chart. The ladder follows the rounds this person records: applied → interviewing → offer with none logged, applied → round 1 → round 2 → … → offer once they are. Per rung: how many got that far, went on, are still there, and left at that point and why — `ended` carries their own LOSS tag names, 'Offer accepted', and 'Lost' for an ending with no reason. Progress is the furthest an application got, so a rejection after two rounds leaks from round two. Use each rung's own `label` and `tone` rather than translating the keys. Wishlist rows are counted separately, not in the funnel; archived applications are excluded. export_funnel_image draws the picture. Says nothing is there yet rather than dividing by zero. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => pipeline.funnelFlows(ctx.userId),
  },
  // The six analyses. They own no tables and write nothing — each is an
  // ordering over reads that already exist, so a number here that disagrees
  // with diagnose_search or list_resumes is a bug in analytics.ts.
  {
    name: "resume_performance",
    title: "Which resume actually gets answered",
    description:
      "Which resume gets replies: per document, how many applications went out with it, how many got a response, an interview, an offer, and how long a response took. Reach for it when they ask which resume to send or whether tailoring was worth it — and before writing a new one, since one on file may already be doing fine. `sent` counts applications past the wishlist with this resume attached, archived ones excluded. A response is the first thing THEY did after you applied — nothing you sent counts, nor anything logged before the apply date. `medianDaysToFirstResponse` covers only those that answered; `stillWaiting` is the rest. Every rate is null under five applications: say the counts ('two of four'), never a percentage. `comparable` false means no two resumes can be ranked — say so instead of naming a winner. `noResume`, applications sent with no document recorded, is often the real finding. Every number is a LOWER BOUND: an unlogged reply does not exist here. Read-only, saves nothing.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => analytics.resumePerformance(ctx.userId),
  },
  {
    name: "loss_report",
    title: "Where applications die, and why",
    description:
      "Every application that ended without an offer, cut six ways: the stage it was actually IN when it died, the recorded reason, where it came from, and the employer's industry, size and location. Reach for it when the question is 'what keeps killing these' — diagnose_search names the weakest funnel step, and this says what the applications leaking from it share. Stage comes from the move itself, falling back to the furthest round reached where no move was recorded; `provenance` counts each. `byReason` uses their own LOSS tags plus a bucket for endings with no reason, and that bucket is usually the finding — the fix is passing lossReasons when moving to LOST. Every rate is null under five applications: say the counts, not a percentage. An application with two source tags counts in both, so those rows do not sum to the total. Wishlist rows and archived applications are not included. Read-only, saves nothing.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => analytics.lossReport(ctx.userId),
  },
  {
    name: "skills_gap",
    title: "What every posting asks for that Me cannot evidence",
    description:
      "What the jobs they keep applying for want that they never wrote down: reads every posting on the pipeline, counts terms by how many DIFFERENT postings use them, and asks search_me for evidence of each. A LEARNING LIST, for 'what should I learn next' or 'why do I keep stalling' — for one posting's fit, use posting_keywords. `missing` is trustworthy: nothing matched any word. `thin` is one record, usually a mention. `backed` only means probably fine, since a two-word term can match on its common word. Then ask about the missing items: half are work they did and never recorded, and what they tell you goes into append_role_background. TRAPS: it knows no synonyms — 'K8s' and 'Kubernetes', or an acronym and its spelled-out name, are different terms — so check before telling anyone they lack something. It describes the jobs THEY chose, not the market. `confident` is false under five postings, where there is no pattern to find. Read `method` before quoting. Read-only, saves nothing.",
    inputSchema: object({
      limit: num("How many terms to test. Default 40, ceiling 80. Each one costs a search of Me."),
      min_postings: num(
        "Only terms appearing in at least this many different postings. Defaults to a quarter of them, never below 3.",
      ),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      analytics.skillsGap(
        ctx.userId,
        defined({ limit: n(args, "limit"), minPostings: n(args, "min_postings") }),
      ),
  },
  {
    name: "research_freshness",
    title: "Which companies you know nothing current about",
    description:
      "Companies with something live riding on them, ordered by WHAT IS COMING UP rather than by how old the research is — a stale note on a wishlist row matters less than one on the company you have a final with on Thursday. Each row gives the state (`never`, `stale` or `fresh`), days since the company was last touched, how many live applications ride on it, and the nearest interview or follow-up the research is for. Reach for it at the start of a week, before interview prep, or when somebody asks what they should be reading. 'Last researched' is approximate: it is the company row's updatedAt, so any edit counts. Follow with get_company and then research_company on the ones that matter, adding to what is written rather than replacing it. Read-only.",
    inputSchema: object({
      stale_after_days: num("How old counts as stale. Default 60"),
      ahead_days: num("How far forward to look for the thing the research is for. Default 21"),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      analytics.researchFreshness(
        ctx.userId,
        defined({ staleAfterDays: n(args, "stale_after_days"), aheadDays: n(args, "ahead_days") }),
      ),
  },
  {
    name: "workspace_health",
    title: "What is thin in this workspace",
    description:
      "Six gaps everything else depends on: companies with no industry, applications with no source, people with no relationship, roles with no highlights, resumes never attached to anything, and a current job nothing has been written into for thirty days. Reach for it when somebody asks how to get more out of the app, when an analysis you ran came back unconfident, or to start a tidy-up — and reach for whoami instead to ask whether they have started at all. Each check returns a count, its total, why it matters, the tool that fixes it and up to five examples WITH IDS, so you can fix them in the same turn. Work through one check at a time, largest share first, and confirm before writing: a source tag is a claim about where something came from, and guessing one is worse than leaving it blank. `clean` lists the checks that found nothing — say what is in good shape too. Nothing here is a fault. Archived records are excluded. Read-only, saves nothing.",
    inputSchema: object({
      examples: num("How many examples to return per check. Default 5, ceiling 25."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      analytics.workspaceHealth(ctx.userId, defined({ examples: n(args, "examples") })),
  },
  {
    name: "morning_brief",
    title: "What changed, what is due, what to do first",
    description:
      "The one call to start a working session with: what happened since they last looked, what is due now, what is coming this week, what has gone quiet, and `first` — an ordered list of what to do, each item carrying the id it is about. Reach for it on 'catch me up', 'what should I do today', 'anything I am forgetting'; reach for pipeline_review for the weekly question and diagnose_search for why the search is not working. `first` is ordered by what it costs to miss — an offer deadline, then anything overdue, then today's calendar, then tasks, pings and silences — and each entry says why it is there. AN EMPTY `first` IS A REAL ANSWER: say nothing is owed today rather than manufacturing work. `changed` and `ahead` include meetings from a connected calendar when there is one, and silently nothing when there is not. The funnel verdict is off unless you pass include_verdict. Read-only, saves nothing: log_follow_up, complete_task and move_application_stage act on it.",
    inputSchema: object({
      since_days: num("How far back 'what changed' looks, in their own time zone. Defaults to 1, or 3 on a Monday."),
      ahead_days: num("How far forward 'what is coming' looks. Default 7."),
      quiet_after_days: num(
        "Applications with nothing logged for this many days count as quiet. Default 14.",
      ),
      first: num("How many items to put in the priority list. Default 5, ceiling 15."),
      include_verdict: bool(
        "Also run the funnel diagnosis and return its headline. Default false — it roughly doubles the cost.",
      ),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      analytics.morningBrief(
        ctx.userId,
        defined({
          sinceDays: n(args, "since_days"),
          aheadDays: n(args, "ahead_days"),
          quietAfterDays: n(args, "quiet_after_days"),
          first: n(args, "first"),
          includeVerdict: b(args, "include_verdict"),
        }),
      ),
  },
  {
    name: "export_funnel_image",
    title: "Export the funnel as an image",
    description:
      "Render the funnel — the Sankey flow chart of where every application ended up — as an image file and return a download url. This is the thing people post: it shows the shape of a search without naming a single company, role or person, so it is safe to share in a way almost nothing else here is. Ask for png when it is going into a message, a slide or a post; svg when it wants to stay sharp at any size, or when this instance has no headless browser to make a png with — the tool says which formats it can actually produce and picks svg when png is not on offer. The url opens in the browser they are already signed in to; it is not a public link and nobody else can fetch it. Nothing is saved: the image is drawn fresh from the pipeline each time it is fetched. Refuses when nothing has been applied to yet.",
    inputSchema: object({
      format: str("png or svg. Defaults to png where the host can render one, svg otherwise."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const png = pdfRenderingAvailable();
      const asked = (s(args, "format") ?? "").toLowerCase();
      if (asked && asked !== "png" && asked !== "svg") {
        throw new Error(`Cannot render "${asked}". Ask for png or svg.`);
      }
      if (asked === "png" && !png) {
        throw new Error(
          "This instance has no headless browser, so it cannot render a PNG. Ask for svg — it opens anywhere an image does.",
        );
      }

      const { applied, rungs, wishlist } = await pipeline.funnelFlows(ctx.userId);
      if (applied === 0) {
        throw new Error(
          "Nothing to chart yet — no applications have been sent, so the funnel is empty.",
        );
      }

      const format = asked || (png ? "png" : "svg");
      const url = `${ctx.baseUrl}/api/funnel/${format}`;
      const stages = rungs.filter((rung) => rung.visited > 0).length;
      return withLinks(
        {
          url,
          format,
          applied,
          wishlist,
          stages,
          pngAvailable: png,
          formats: png ? ["png", "svg"] : ["svg"],
        },
        [
          {
            type: "resource_link",
            uri: url,
            name: `Job search funnel (${format.toUpperCase()})`,
            description: `${applied} application${applied === 1 ? "" : "s"} across ${stages} stage${stages === 1 ? "" : "s"}. No company or role names on it.`,
            mimeType: format === "png" ? "image/png" : "image/svg+xml",
          },
        ],
      );
    },
  },
  {
    name: "share_pipeline",
    title: "Get a read-only link to the pipeline",
    description:
      "Mint a link that shows this person's pipeline to anyone holding it, no login — for a friend, coach or former manager helping review the search. Returns publicUrl: hand it straight to the user. Calling twice for the same thing returns the same link. Pass saved_view_id (from list_saved_views) to share ONLY what that view shows — usually the better offer, since 'everything from a referral' is reasonable to send and a whole board is not. Each shared thing has its own address and switches, so revoking one leaves the others. A viewer sees company, role, stage, location, time in stage and when a follow-up is due — NOT notes, job descriptions, salary, contacts or the timeline. Say so if asked what will be visible: a share link is consent to show a search, not to publish the people in it.",
    inputSchema: object({
      include_closed: bool("Show accepted / rejected / withdrawn / ghosted applications too. Default false."),
      saved_view_id: str("Share only this saved view. Omit for the whole pipeline"),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const share = await pipelineShare.sharePipeline(ctx.userId, {
        includeClosed: b(args, "include_closed"),
        savedViewId: s(args, "saved_view_id") ?? null,
      });
      const publicUrl = publicPipelineUrl(ctx.baseUrl, share.slug);
      return withLinks({ ...share, publicUrl }, [
        {
          type: "resource_link",
          uri: publicUrl,
          name: "Shared pipeline",
          description:
            "Read-only pipeline. Shows company, role, stage and follow-up dates — never notes, salary or contacts.",
          mimeType: "text/html",
        },
      ]);
    },
  },
  {
    name: "unshare_pipeline",
    title: "Revoke the pipeline link",
    description:
      "Stop sharing. This DESTROYS the address rather than pausing it — anyone holding the old link gets nothing, and sharing again later mints a completely different URL. That is deliberate: the reason to revoke is usually that a link reached someone it should not have, and a pause that can be undone does not fix that. With no arguments it revokes the whole-pipeline link and leaves any saved-view links alone; pass saved_view_id for one of those, or all true to kill every link at once, which is the answer to 'stop sharing anything'. Returns how many it revoked.",
    inputSchema: object({
      saved_view_id: str("Revoke the link for this saved view rather than the whole-pipeline one"),
      all: bool("Revoke every share link this person has"),
    }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipelineShare.unsharePipeline(ctx.userId, {
        savedViewId: s(args, "saved_view_id") ?? null,
        all: b(args, "all"),
      }),
  },
  {
    name: "get_pipeline_share",
    title: "Check whether the pipeline is shared",
    description:
      "Every read-only link this person has minted, what each one shows, and when it was last opened. The one with savedView null is the whole pipeline; the rest each show one saved view. Returns an empty list when nothing is shared. Use it before minting a link so you can tell someone they already have one, and to answer 'has anyone actually looked at it' — lastViewedAt null means nobody has opened it yet.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => {
      const shares = await pipelineShare.listPipelineShares(ctx.userId);
      if (shares.length === 0) return [];
      const rows = shares.map((share) => ({
        ...share,
        publicUrl: publicPipelineUrl(ctx.baseUrl, share.slug),
      }));
      return withLinks(
        rows,
        rows.map((share) => ({
          type: "resource_link" as const,
          uri: share.publicUrl,
          name: share.savedView ? `Shared view: ${share.savedView.name}` : "Shared pipeline",
          mimeType: "text/html",
        })),
      );
    },
  },
  {
    name: "list_saved_views",
    title: "List saved pipeline views",
    description:
      "The cuts of the pipeline this person has named and kept — 'Chasing', 'Dream jobs', 'Gone quiet'. Each one returns a name and a query string like \"view=list&f=INTERVIEWING&sort=waiting\". Call this when someone refers to a view by name, then read the query to work out what they mean — save_view documents every parameter it can hold. Reading a view tells you what they consider one job; it is a good place to look before asking what they want reviewed.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => views.listSavedViews(ctx.userId),
  },
  {
    name: "save_view",
    title: "Save a pipeline view under a name",
    description:
      "Name a cut of the pipeline so it reopens in one click. `query` is the pipeline URL's parameters without the '?', all combined: view (board | list | calendar); f (comma-separated stages, plus 'overdue' as a flag that ANDs with them and 'closed' for both endings; retired stage names still map to their replacements); src (tag ids from list_tags); co (company ids); cv (resume ids, or 'none' for no resume); w (minimum days in the current stage); qd (minimum days since anything was logged — the chasing question, not the same as w); sort (followUp | company | stage | updated | salary | waiting | quiet) and dir; q (search across company, role, notes, location, work mode, posting text and tag names); month (YYYY-MM, calendar only). Anything else is dropped. Example: 'Referrals gone quiet' = 'view=list&f=APPLIED,INTERVIEWING&qd=14&sort=quiet&dir=desc'. Saving under an existing name REPLACES that view, which is how you edit one. co and cv hold ids, so a company later merged away simply stops matching.",
    inputSchema: object(
      {
        name: str("What to call it, e.g. 'Chasing'"),
        query: str("The pipeline query string, without the leading '?'"),
      },
      ["name", "query"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      views.saveView(ctx.userId, required(args, "name"), s(args, "query") ?? ""),
  },
  {
    name: "delete_saved_view",
    title: "Delete a saved view",
    description:
      "Remove a saved pipeline view. Only the view goes — nothing about the applications it was showing is touched. Get the id from list_saved_views.",
    inputSchema: object({ id: str("Saved view id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => views.deleteSavedView(ctx.userId, required(args, "id")),
  },
  {
    name: "list_schedule",
    title: "List everything dated in a window",
    description:
      "Everything with a date between two dates, merged and sorted earliest first: follow-ups coming due, tasks with a due date, activity already logged (calls, interviews, emails, stage changes) and — with Google Calendar connected — meetings involving someone on the pipeline, matched by attendee. The tool for 'what does my week look like', 'what happened last month', 'what is coming up' — questions about a period rather than one application. Each entry has its kind (FOLLOW_UP, TASK, ACTIVITY or MEETING), date, title, company and applicationId for get_application; a MEETING also carries its calendar `url`. Reach for list_follow_ups instead for only what is overdue, and list_tasks when the date does not matter. Read-only; it saves nothing.",
    inputSchema: object(
      {
        from: str("Start of the window, ISO date (YYYY-MM-DD). Inclusive."),
        to: str("End of the window, ISO date (YYYY-MM-DD). Inclusive."),
      },
      ["from", "to"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      // Both edges go through as written: listSchedule owns what a bare date
      // means, so the tool and the calendar screen cannot disagree about it.
      schedule.listSchedule(ctx.userId, required(args, "from"), required(args, "to")),
  },
  {
    name: "list_tasks",
    title: "List tasks",
    description:
      "To-dos, each with a due date and — at most — one thing it is about: an application, a company, a person, a resume, a role in Me, or a note, whichever is set. Open ones first, soonest due first. This is what someone means by 'what do I need to do' — pair it with list_follow_ups, which covers the chasing this list deliberately does not: an application's follow-up date and a person's ping are not tasks and never appear here.",
    inputSchema: object({
      done: bool("Filter by completion state. Omit for all."),
      limit: num("How many at most. Default 100."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.listTasks(ctx.userId, { done: b(args, "done"), limit: n(args, "limit") }),
  },
  {
    name: "create_task",
    title: "Create a task",
    description:
      "Add a to-do, with a due date and at most one thing it is about: an application, a company, a person, a resume, a role in Me or a note. Pass the id of that ONE, or none for a task about nothing in particular; two is refused. Attached, it shows on that record's own screen and goes with it if the record is deleted. Ids not theirs, or archived, are refused. Put what the task involves in `detail`, not the title. Pass repeat_unit to make it recur: completing one creates only the NEXT, so ignored weeks never pile up, and dates step from the first, so 'every Monday' stays on a Monday.",
    inputSchema: object(
      {
        title: str("What needs doing"),
        detail: str("The longer version — what it involves, who said it, what to reference"),
        dueAt: str("ISO date it is due"),
        applicationId: str("Attach to this application"),
        companyId: str("Or to a company"),
        contactId: str("Or to a person"),
        resumeId: str("Or to a resume"),
        roleId: str("Or to a role in Me"),
        noteId: str("Or to a note"),
        repeat_unit: {
          type: "string",
          enum: ["DAY", "WEEK", "MONTH"],
          description:
            "Make it repeat. Completing it then creates the next one. Send an empty string to stop a series.",
        },
        repeat_every: num("How many units between instances. 1 unless you say otherwise."),
        repeat_until: str("The last day the series may produce an instance, YYYY-MM-DD. Omit to run until stopped."),
      },
      ["title"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.createTask(ctx.userId, {
        title: required(args, "title"),
        ...defined({
          detail: s(args, "detail"),
          dueAt: s(args, "dueAt"),
          applicationId: s(args, "applicationId"),
          companyId: s(args, "companyId"),
          contactId: s(args, "contactId"),
          resumeId: s(args, "resumeId"),
          roleId: s(args, "roleId"),
          noteId: s(args, "noteId"),
          ...repeatFrom(args),
        }),
      }),
  },
  {
    name: "complete_task",
    title: "Complete or reopen a task",
    description:
      "Mark a task done, or reopen it with done: false. When the task repeats, completing it also creates the NEXT instance and returns it as `next` — one, never one per missed week. Reopening and completing again does not make a second: the new instance points back at the one that made it, so the duplicate is refused rather than written.",
    inputSchema: object({ id: str("Task id"), done: bool("Default true") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) => pipeline.setTaskDone(ctx.userId, required(args, "id"), b(args, "done") ?? true),
  },
  {
    name: "update_task",
    title: "Update a task",
    description:
      "Reword a task, move its due date, or hook it to something different. Only the fields you pass change; each REPLACES what was there. Pass an empty string for dueAt to clear the date, or for any of the subject ids to unhook it. Setting one subject CLEARS the others, because a task is about at most one thing — so moving a task from an application to a person is one call with contactId, not two. Use complete_task to tick it off; done is not settable here. THE TRAP with repeats: moving this instance's due date moves THIS instance, not the series, because every future date is stepped from the first one. Sending repeat_unit as an empty string stops the series, and completing the task is then the end of it — which is the whole off switch and is on the row somebody is already looking at.",
    inputSchema: object(
      {
        id: str("Task id"),
        title: str("What needs doing"),
        detail: str("The longer version — replaces what is there"),
        dueAt: str("ISO date it is due, or empty string to clear it"),
        applicationId: str("Attach to this application, or empty string to unhook"),
        companyId: str("Or to a company"),
        contactId: str("Or to a person"),
        resumeId: str("Or to a resume"),
        roleId: str("Or to a role in Me"),
        noteId: str("Or to a note"),
        repeat_unit: {
          type: "string",
          enum: ["DAY", "WEEK", "MONTH"],
          description:
            "Make it repeat. Completing it then creates the next one. Send an empty string to stop a series.",
        },
        repeat_every: num("How many units between instances. 1 unless you say otherwise."),
        repeat_until: str("The last day the series may produce an instance, YYYY-MM-DD. Omit to run until stopped."),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.updateTask(
        ctx.userId,
        required(args, "id"),
        defined({
          title: s(args, "title"),
          detail: s(args, "detail"),
          dueAt: s(args, "dueAt"),
          applicationId: s(args, "applicationId"),
          companyId: s(args, "companyId"),
          contactId: s(args, "contactId"),
          resumeId: s(args, "resumeId"),
          roleId: s(args, "roleId"),
          noteId: s(args, "noteId"),
          ...repeatFrom(args),
        }),
      ),
  },
  {
    name: "delete_task",
    title: "Delete a task",
    description:
      "Remove a task outright. Use complete_task instead when it actually got done — a finished task is worth keeping, and this is not undoable.",
    inputSchema: object({ id: str("Task id") }, ["id"]),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => pipeline.deleteTask(ctx.userId, required(args, "id")),
  },
  {
    name: "export_csv",
    title: "Export a list as CSV",
    description:
      "One of the three lists as CSV text you can hand straight to somebody. 'companies' and 'contacts' take the same filters as list_companies and list_contacts, so narrow it there first and pass the same arguments here. 'applications' takes the pipeline's own filters through `query`, the string the app puts in its URL. Every export ALWAYS includes closed applications. `ids` INTERSECTS with the other arguments rather than replacing them, so send it alone unless you mean 'these rows, and only those that also match'. Archived records are never included; list_archive has those. Read-only: it writes nothing.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: [...EXPORT_KINDS],
          description: "companies | contacts | applications",
        },
        ids: strArray("Only these rows, by id. Narrows whatever the other arguments left, so on its own it is exactly this set."),
        search: str("For companies and contacts: the same search the list tools take"),
        filter: str("For companies and contacts: the same one-word cut the list tools take"),
        tagIds: strArray("For companies and contacts: tag ids, as list_companies takes them"),
        industryIds: strArray("For companies: tag ids of kind INDUSTRY"),
        sizeIds: strArray("For companies: tag ids of kind SIZE"),
        locationIds: strArray("For companies: tag ids of kind LOCATION"),
        companyIds: strArray("For contacts: only people linked to these companies"),
        quietDays: num("For contacts: nothing logged for at least this many days"),
        missing: strArray("The same blank-field cuts the list tools take. These AND."),
        sort: str("The same sort key the matching list tool takes"),
        dir: { type: "string", enum: [...SORT_DIRECTIONS], description: "asc | desc" },
        query: str(
          "For applications: a pipeline query string, e.g. \"f=INTERVIEWING&src=<tagId>\" — the same one the app puts in its URL",
        ),
      },
      ["kind"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const kind = enumArg(args, "kind", EXPORT_KINDS) ?? "companies";
      const ids = a(args, "ids");
      const dir = enumArg(args, "dir", SORT_DIRECTIONS);
      if (kind === "companies") {
        return {
          filename: exportFilename("companies", await me.timeZoneOf(ctx.userId)),
          csv: await exportCompaniesCsv(
            ctx.userId,
            defined({
              search: s(args, "search"),
              filter: enumArg(args, "filter", COMPANY_FILTERS),
              tagIds: a(args, "tagIds"),
              industryIds: a(args, "industryIds"),
              sizeIds: a(args, "sizeIds"),
              locationIds: a(args, "locationIds"),
              missing: enumArrayArg(args, "missing", COMPANY_MISSING),
              sort: enumArg(args, "sort", COMPANY_SORTS),
              dir,
              ids,
            }),
          ),
        };
      }
      if (kind === "contacts") {
        return {
          filename: exportFilename("contacts", await me.timeZoneOf(ctx.userId)),
          csv: await exportContactsCsv(
            ctx.userId,
            defined({
              search: s(args, "search"),
              filter: enumArg(args, "filter", CONTACT_FILTERS),
              tagIds: a(args, "tagIds"),
              companyIds: a(args, "companyIds"),
              quietDays: n(args, "quietDays"),
              missing: enumArrayArg(args, "missing", CONTACT_MISSING),
              sort: enumArg(args, "sort", CONTACT_SORTS),
              dir,
              ids,
            }),
          ),
        };
      }
      const query = s(args, "query");
      return {
        filename: exportFilename("applications", await me.timeZoneOf(ctx.userId)),
        csv: await exportApplicationsCsv(
          ctx.userId,
          defined({
            filters: query
              ? parsePipelineFilters(
                  (key) => new URLSearchParams(query).get(key) ?? undefined,
                  pipeline.STAGES,
                )
              : undefined,
            ids,
          }),
        ),
      };
    },
  },
  {
    name: "list_field_values",
    title: "Locations and work modes already in use",
    description:
      "Every location and every work mode already on one of this person's live applications, most-used first with a count each. Call it BEFORE writing either field on create_application or update_application: both are free text and always will be — 'Remote (US, PST overlap)' is a real answer no list survives — but writing 'remote' next to an existing 'Remote' leaves two values that never group together, and this is the cheap way not to. Match case-insensitively against what comes back and reuse that spelling; a genuinely new value is still just a string, so send it. Archived applications do not contribute. Read-only.",
    inputSchema: object({}),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (_args, ctx) => pipeline.applicationFieldValues(ctx.userId),
  },
  // --- CRM: companies and the people at them -------------------------------
  {
    name: "list_companies",
    title: "List companies",
    description:
      "Every company on file, with counts of applications and contacts, lastAppliedAt, openApplications and its tags (industry, size, location and free tags). Reach for this to answer 'who have I applied to', to find a companyId before get_company, or to cut the list to something specific — 'fintech, remote, never applied' is one call. Filters AND together; each parameter says what it matches. Tag filters take IDS ONLY: call list_tags first to turn 'fintech' into an id — a name here narrows nothing and hands back every company as if that were the answer. Archived companies are never returned; list_archive has them. Read-only: saves nothing, creates no tags. Capped at 100 rows unless you raise `limit`, and a cut-off result says how many there were before the data, so narrow the filters rather than reporting a short list as everything.",
    inputSchema: object({
      search: str("Match name, website, notes or any tag — industry and location included"),
      tagIds: strArray("Only companies wearing one of these tags, of any kind — for an id whose list you do not care about. Ids from list_tags."),
      industryIds: strArray("Tag ids of kind INDUSTRY. Matches a company wearing any of them; ANDs with the other groups."),
      sizeIds: strArray("Tag ids of kind SIZE. Matches any of them; ANDs with the other groups."),
      locationIds: strArray("Tag ids of kind LOCATION. Matches any of them; ANDs with the other groups."),
      missing: {
        type: "array",
        items: { type: "string", enum: [...COMPANY_MISSING] },
        description: "Fields that are blank: website | industry | location. These AND with each other.",
      },
      filter: {
        type: "string",
        enum: [...COMPANY_FILTERS],
        description: "One cut, and only one: active | applied | never-applied | with-contacts",
      },
      sort: {
        type: "string",
        enum: [...COMPANY_SORTS],
        description: "name | applied | apps | people. Default name. Every sort but name defaults to most-first, and companies never applied to sort last whichever way 'applied' points.",
      },
      dir: { type: "string", enum: [...SORT_DIRECTIONS], description: "asc | desc" },
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await pipeline.listCompanies(
          ctx.userId,
          defined({
            search: s(args, "search"),
            tagIds: a(args, "tagIds"),
            industryIds: a(args, "industryIds"),
            sizeIds: a(args, "sizeIds"),
            locationIds: a(args, "locationIds"),
            missing: enumArrayArg(args, "missing", COMPANY_MISSING),
            filter: enumArg(args, "filter", COMPANY_FILTERS),
            sort: enumArg(args, "sort", COMPANY_SORTS),
            dir: enumArg(args, "dir", SORT_DIRECTIONS),
          }),
        ),
        n(args, "limit"),
        100,
      ),
  },
  {
    name: "get_company",
    title: "Get a company",
    description:
      "Everything on file for one company: website, industry, size, location, your research notes, every application you have with them, and every person on file who represents it — someone can represent more than one company, so a name here is not necessarily their day job. This is the tool to call before writing anything about a company, so you add to what is known rather than replacing it.",
    inputSchema: object({ id: str("Company id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      // Every other get_* raises here rather than returning null, and it has to:
      // a null serialises as {"ok": true}, which reads like a hit.
      const company = await pipeline.getCompany(ctx.userId, required(args, "id"));
      if (!company) throw new Error(`No company with id ${required(args, "id")}`);
      return company;
    },
  },
  {
    name: "create_company",
    title: "Create a company",
    description:
      "Add a company before you have applied to them — somewhere to keep research while you decide. Applications create their company automatically, so reach for this only when there is no application yet. Names are unique per person; creating one that already exists is an error rather than a silent merge.",
    inputSchema: object(
      {
        name: str("Company name"),
        website: str("Their own site, e.g. stripe.com. This is what the logo comes from."),
        industry: strArray("What they do, one or more: 'Fintech', 'Developer tools'. Tags — REPLACES the set."),
        industryIds: strArray("Industry tag ids from list_tags. Exact; wins over industry."),
        size: strArray("Headcount or stage: '200-500', 'Series B'. Tags — REPLACES the set."),
        sizeIds: strArray("Size tag ids. Exact; wins over size."),
        location: strArray("Where they are, one or more. Tags — REPLACES the set."),
        locationIds: strArray("Location tag ids. Exact; wins over location."),
        tags: strArray("Anything else worth filing them under. REPLACES the set."),
        tagIds: strArray("Company tag ids. Exact; wins over tags."),
        notes: str("Anything you have learned about them"),
      },
      ["name"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.createCompany(ctx.userId, {
        name: required(args, "name"),
        ...defined({
          website: s(args, "website"),
          industry: a(args, "industry"),
          industryIds: a(args, "industryIds"),
          size: a(args, "size"),
          sizeIds: a(args, "sizeIds"),
          location: a(args, "location"),
          locationIds: a(args, "locationIds"),
          tags: a(args, "tags"),
          tagIds: a(args, "tagIds"),
          notes: s(args, "notes"),
        }),
      }),
  },
  {
    name: "update_company",
    title: "Update a company",
    description:
      "Change what you know about a company. Only the fields you pass are touched, but each one REPLACES what was there — notes especially, so call get_company first and write back the whole thing if you are adding to research rather than replacing it. Industry, size, location and tags are lists of labels now rather than single strings: a company can be fintech AND developer tools, and in three offices. Each list replaces its own set and leaves the other three alone. Setting website is the single thing that makes their logo show in the pipeline; a job board URL is not their website.",
    inputSchema: object(
      {
        id: str("Company id"),
        name: str("Company name"),
        website: str("Their own site, e.g. stripe.com"),
        industry: strArray("What they do, one or more. REPLACES the set; an empty list clears it."),
        industryIds: strArray("Industry tag ids from list_tags. Exact; wins over industry."),
        size: strArray("Headcount or stage. REPLACES the set."),
        sizeIds: strArray("Size tag ids. Exact; wins over size."),
        location: strArray("Where they are, one or more. REPLACES the set."),
        locationIds: strArray("Location tag ids. Exact; wins over location."),
        tags: strArray("Anything else worth filing them under. REPLACES the set."),
        tagIds: strArray("Company tag ids. Exact; wins over tags."),
        notes: str("Research notes — replaces what is there"),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.updateCompany(
        ctx.userId,
        required(args, "id"),
        defined({
          name: s(args, "name"),
          website: s(args, "website"),
          industry: a(args, "industry"),
          industryIds: a(args, "industryIds"),
          size: a(args, "size"),
          sizeIds: a(args, "sizeIds"),
          location: a(args, "location"),
          locationIds: a(args, "locationIds"),
          tags: a(args, "tags"),
          tagIds: a(args, "tagIds"),
          notes: s(args, "notes"),
        }),
      ),
  },
  {
    name: "preview_company_merge",
    title: "Preview merging two companies",
    description:
      "What merging one company into another WOULD do, without doing any of it. Call this first, every time — merge_companies is irreversible and takes an argument order that is easy to get backwards. Returns how many applications and contacts would move, which of them by role title, which blank fields on the survivor would be filled from the duplicate, and whether the duplicate's notes would be appended. Show that to the person before you merge. Read-only.",
    inputSchema: object(
      {
        keep_id: str("Company id that SURVIVES the merge, with its name"),
        merge_id: str("Company id that is folded in and then DELETED"),
      },
      ["keep_id", "merge_id"],
    ),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.previewCompanyMerge(ctx.userId, required(args, "keep_id"), required(args, "merge_id")),
  },
  {
    name: "merge_companies",
    title: "Merge one company into another",
    description:
      "Fix the same employer being on file twice — 'Stripe', 'Stripe, Inc.' and 'stripe'. Every application and contact on merge_id moves to keep_id, blank fields on keep_id are filled from the duplicate, its notes are APPENDED under a line saying where they came from, and the duplicate is deleted. DESTRUCTIVE and IRREVERSIBLE: the merge_id company ceases to exist and nothing records which applications came from which side. Call preview_company_merge first and get the person's yes. The direction is not guessable — keep_id is the name that lives on. Nothing is de-duplicated: two identical role titles on the survivor is correct. A later create_application naming the old spelling will create it again as an empty company.",
    inputSchema: object(
      {
        keep_id: str("Company id that SURVIVES, keeping its name"),
        merge_id: str("Company id that is folded in and then DELETED"),
      },
      ["keep_id", "merge_id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.mergeCompanies(ctx.userId, required(args, "keep_id"), required(args, "merge_id")),
  },
  {
    name: "list_contacts",
    title: "List contacts",
    description:
      "Recruiters, hiring managers, referrals and the friend who might put in a word. Reach for this to find a contactId before get_contact or update_contact, to see who you know somewhere before an interview, or to build a list to work through. Returns each person with `companies` (a list — someone can be a founder at one place and an advisor at another), `tags`, next ping date, attached application and latest logged activity. Filters AND together; each parameter says what it matches. log_activity with a contactId moves their last-touch date; update_contact's nextFollowUpAt schedules the next ping. Archived people are never returned; list_archive has them. Read-only; it saves nothing. Capped at 100 rows unless you raise `limit`, and a cut-off result says how many there were before the data, so narrow the filters rather than reporting a short list as everything.",
    inputSchema: object({
      applicationId: str("Limit to one application"),
      companyId: str("Limit to people linked to one company — linked, not employed by, so an advisor there counts"),
      companyIds: strArray("Limit to people linked to ANY of these companies — linked, not employed by"),
      search: str("Match name, title, relationship, email, notes, employer names or tag names"),
      tagIds: strArray("Only people wearing one of these tags. Ids from list_tags, kind CONTACT."),
      quietDays: num("Only people with nothing logged for at least this many days, counting from the day they were added when nothing was ever logged — so people filed and forgotten come back"),
      missing: {
        type: "array",
        items: { type: "string", enum: [...CONTACT_MISSING] },
        description: "Fields that are blank: 'email' (nobody you can write to) | 'tags' (filed under nothing, so no tag filter finds them). These AND with each other.",
      },
      filter: {
        type: "string",
        enum: [...CONTACT_FILTERS],
        description: "One cut, and only one: ping-due | with-application | no-company",
      },
      sort: {
        type: "string",
        enum: [...CONTACT_SORTS],
        description: "name | company (nobody on file last) | ping (soonest first, no date last) | touch (longest since anything was logged, first). Default name.",
      },
      dir: { type: "string", enum: [...SORT_DIRECTIONS], description: "asc | desc" },
      limit: limitArg(100),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      capped(
        await pipeline.listContacts(
          ctx.userId,
          defined({
            applicationId: s(args, "applicationId"),
            companyId: s(args, "companyId"),
            companyIds: a(args, "companyIds"),
            search: s(args, "search"),
            tagIds: a(args, "tagIds"),
            quietDays: n(args, "quietDays"),
            missing: enumArrayArg(args, "missing", CONTACT_MISSING),
            filter: enumArg(args, "filter", CONTACT_FILTERS),
            sort: enumArg(args, "sort", CONTACT_SORTS),
            dir: enumArg(args, "dir", SORT_DIRECTIONS),
          }),
        ),
        n(args, "limit"),
        100,
      ),
  },
  {
    name: "get_contact",
    title: "Get a contact",
    description:
      "One person in full, with every company they represent (`companies`) and the application they belong to. Call this before update_contact so you know what you are about to overwrite. Also returns their timeline — every call, coffee and reply logged with log_activity, newest first — so 'when did I last talk to them' is answered from here.",
    inputSchema: object({ id: str("Contact id") }, ["id"]),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const contact = await pipeline.getContact(ctx.userId, required(args, "id"));
      if (!contact) throw new Error(`No contact with id ${required(args, "id")}`);
      return contact;
    },
  },
  {
    name: "update_contact",
    title: "Update a contact",
    description:
      "Change a person's details. Only the fields you pass are touched, and each REPLACES what was there — read first with get_contact if you are adding to notes, otherLinks, companies or tags rather than replacing them. A person can represent several companies at once, so `companies` is a list and REPLACES the whole set: to add one, read the current list, append, and pass it all back. An empty list detaches them from every company. `tags` behaves the same way. applicationId works the same way too, with an empty string to detach.",
    inputSchema: object(
      {
        id: str("Contact id"),
        name: str("Their name"),
        title: str("Their job title"),
        email: str("Email"),
        phone: str("Phone"),
        linkedin: str("LinkedIn URL"),
        twitter: str("X / Twitter — a URL or an @handle"),
        instagram: str("Instagram — a URL or an @handle"),
        github: str("GitHub profile URL"),
        website: str("Their own site, blog or portfolio"),
        otherLinks: strArray(
          "Anywhere else they are reachable that has no field of its own — Bluesky, Mastodon, a Substack. REPLACES the whole list, so read the current one from get_contact first.",
        ),
        relationship: str("e.g. 'recruiter', 'hiring manager', 'referral'"),
        notes: str("Notes — replaces what is there"),
        companyIds: strArray("Company ids. Exact; wins over companies. REPLACES the whole set."),
        companies: strArray(
          "Company names — everywhere this person represents. REPLACES the whole set; a name nothing matches is created.",
        ),
        company: str(
          "Legacy single-company spelling. WARNING: this also REPLACES the whole set with just this one — read the current list from get_contact first, or use companies. Empty string detaches every company. Ignored when companies is passed.",
        ),
        tagIds: strArray("Tag ids, kind CONTACT. Exact; wins over tags. REPLACES the whole set."),
        tags: strArray(
          "Tag names — how they are filed. REPLACES the whole set; a name nothing matches is created. Empty list clears them.",
        ),
        applicationId: str("Application to attach to, or empty string to detach"),
        nextFollowUpAt: str("ISO date to next get in touch — 'ping Sarah in two weeks' lives here. Empty string clears it. Due pings surface in list_follow_ups and on the dashboard."),
      },
      ["id"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.updateContact(
        ctx.userId,
        required(args, "id"),
        defined({
          name: s(args, "name"),
          title: s(args, "title"),
          email: s(args, "email"),
          phone: s(args, "phone"),
          linkedin: s(args, "linkedin"),
          twitter: s(args, "twitter"),
          instagram: s(args, "instagram"),
          github: s(args, "github"),
          website: s(args, "website"),
          otherLinks: a(args, "otherLinks"),
          relationship: s(args, "relationship"),
          notes: s(args, "notes"),
          companyIds: a(args, "companyIds"),
          companies: a(args, "companies"),
          company: s(args, "company"),
          tagIds: a(args, "tagIds"),
          tags: a(args, "tags"),
          applicationId: s(args, "applicationId"),
          nextFollowUpAt: s(args, "nextFollowUpAt"),
        }),
      ),
  },
  {
    name: "create_contact",
    title: "Create a contact",
    description:
      "Save a person: recruiter, hiring manager, referral, friend at the company. Record every way you can reach them — linkedin, twitter, instagram, github, website, and otherLinks for anything else — because the one that matters is whichever they actually answer on, and a name with no way to contact it is a dead row. Pass `companies` for everywhere they represent, not just their day job: an angel who also advises two of your targets is three links, and each one is created if it does not exist yet. `tags` files them alongside everyone else you have labelled the same way.",
    inputSchema: object(
      {
        name: str("Their name"),
        title: str("Their job title"),
        email: str("Email"),
        phone: str("Phone"),
        linkedin: str("LinkedIn URL"),
        twitter: str("X / Twitter — a URL or an @handle"),
        instagram: str("Instagram — a URL or an @handle"),
        github: str("GitHub profile URL"),
        website: str("Their own site, blog or portfolio"),
        otherLinks: strArray(
          "Anywhere else they are reachable that has no field of its own — Bluesky, Mastodon, a Substack. REPLACES the whole list, so read the current one from get_contact first.",
        ),
        relationship: str("e.g. 'recruiter', 'hiring manager', 'referral'"),
        notes: str("Notes"),
        companyIds: strArray("Company ids, when you already have them. Wins over companies."),
        companies: strArray(
          "Every company they represent, by name. Any that do not exist yet are created.",
        ),
        company: str("Legacy single-company spelling. Ignored when companies is passed."),
        tagIds: strArray("Tag ids from list_tags, kind CONTACT. Exact; wins over tags."),
        tags: strArray(
          "How they are filed — 'referral', 'warm intro', 'ex-colleague'. Names; any that do not exist yet are created.",
        ),
        applicationId: str("Attach to this application"),
      },
      ["name"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      pipeline.createContact(ctx.userId, {
        name: required(args, "name"),
        ...defined({
          title: s(args, "title"),
          email: s(args, "email"),
          phone: s(args, "phone"),
          linkedin: s(args, "linkedin"),
          twitter: s(args, "twitter"),
          instagram: s(args, "instagram"),
          github: s(args, "github"),
          website: s(args, "website"),
          otherLinks: a(args, "otherLinks"),
          relationship: s(args, "relationship"),
          notes: s(args, "notes"),
          companyIds: a(args, "companyIds"),
          companies: a(args, "companies"),
          company: s(args, "company"),
          tagIds: a(args, "tagIds"),
          tags: a(args, "tags"),
          applicationId: s(args, "applicationId"),
        }),
      }),
  },
  {
    name: "tag_records",
    title: "Tag companies or people in bulk",
    description:
      "Add or remove tags across a set of companies or a set of people in one act — 'these nine are all fintech', 'take Dream list off these four'. Pass `kind`: company or contact. ADD and REMOVE only, never replace, so nothing else on those records is stripped. Tag ids only, from list_tags. A company takes INDUSTRY, SIZE, LOCATION or COMPANY tags; a person takes CONTACT; any other kind is refused. Ids not theirs, or archived, are skipped. Returns which records changed and which were skipped. To set one record's lists deliberately, use update_company or update_contact; this is for a selection.",
    inputSchema: object(
      {
        kind: { type: "string", enum: ["company", "contact"], description: "Which records the ids are" },
        ids: strArray("Company or contact ids to change"),
        add: strArray("Tag ids to attach"),
        remove: strArray("Tag ids to take off"),
      },
      ["kind", "ids"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      const change = { add: a(args, "add"), remove: a(args, "remove") };
      const ids = requiredArray(args, "ids");
      const kind = required(args, "kind");
      if (kind === "company") return pipeline.tagCompanies(ctx.userId, ids, change);
      if (kind === "contact") return pipeline.tagContacts(ctx.userId, ids, change);
      throw new Error('kind must be "company" or "contact".');
    },
  },
  {
    name: "schedule_contact_pings",
    title: "Put people on the chase list",
    description:
      "Set one next-ping date across a set of people — 'chase everyone I met at the conference in two weeks'. Takes an ISO date; an empty string clears the date instead, taking all of them off the chase list. A date it cannot read is REFUSED rather than treated as empty, so a vague 'next Tuesday' fails loudly instead of silently unscheduling everybody in the batch. Due pings surface in list_follow_ups and list_schedule alongside due applications. Ids that are not this person's, or are in the archive, are skipped. Use update_contact's nextFollowUpAt for one person.",
    inputSchema: object(
      {
        ids: strArray("Contact ids"),
        date: str("ISO date to ping them, or an empty string to clear it"),
      },
      ["ids", "date"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) => {
      // Not `required`, which refuses an empty string — and an empty string is
      // the documented way to take everyone off the chase list.
      const date = args["date"];
      if (typeof date !== "string") {
        throw new Error('Missing required string argument "date"');
      }
      return pipeline.scheduleContactPings(ctx.userId, requiredArray(args, "ids"), date);
    },
  },

  // --- ARCHIVE: what has been deleted -------------------------------------
  {
    name: "list_archive",
    title: "What is in the archive",
    description:
      "Everything this person deleted and can still get back. Deleting a company, a person or an application puts it in the archive, deleted for good a set number of days later — this tool reports the figure in force. Reach for it when they ask where something went, deleted something by mistake, or want to know what is about to disappear. One row per item: kind, id, name, a subtitle, when it was archived, and purgeAt — when it goes for good, or null when this instance keeps things forever. Each row says what would come back with it ('restoring Stripe brings 3 applications back'), and flags a company whose name a live company has since taken, the one thing that makes a restore fail. Pass kind and ids to restore_records, or to delete_archived to finish the job now. Read-only: saves nothing, purges nothing.",
    inputSchema: object({
      kind: {
        type: "string",
        enum: [...ARCHIVE_KIND_VALUES],
        description: "Only this kind: company | contact | application",
      },
      search: str("Match the name, or a role title and its company"),
      limit: num("How many of each kind at most. Default 200."),
    }),
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      archive.listArchive(
        ctx.userId,
        defined({
          kind: enumArg(args, "kind", ARCHIVE_KIND_VALUES),
          search: s(args, "search"),
          limit: n(args, "limit"),
        }),
      ),
  },
  {
    name: "archive_records",
    title: "Delete, reversibly",
    description:
      "Delete records the reversible way — THE tool whenever somebody says to delete or remove a company, a person or an application, one id or many, one kind per call. They leave every list, board, filter and count and land in the archive, where restore_records brings them back for a set number of days (30 by default) before they go for good. Three things it is not: a job that ended is move_application_stage to LOST with a reason, because archiving takes it out of the funnel's record; a duplicate employer is merge_companies; and taking somebody off one application is update_contact with an empty applicationId. Archiving a company takes every application still pointing at it, timelines and tasks included, and restores them together. The people at that company are NOT archived — they just lose this company. Ids not theirs, or already archived, are skipped. Nothing here is permanent; delete_archived is.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: [...ARCHIVE_KIND_VALUES],
          description: "company | contact | application",
        },
        ids: strArray("Ids of that kind"),
      },
      ["kind", "ids"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      archive.archiveRecords(
        ctx.userId,
        enumArg(args, "kind", ARCHIVE_KIND_VALUES) ?? "company",
        requiredArray(args, "ids"),
      ),
  },
  {
    name: "restore_records",
    title: "Bring archived records back",
    description:
      "Take records out of the archive and put them back in the app. Call list_archive first for the kind and ids. Restoring a company also restores the applications archived WITH it — not one the person archived separately beforehand. Restoring an application whose company is archived brings the company back too. Ids not in the archive are skipped, so restoring twice is harmless. The one real failure is a name: restoring 'Stripe' while a live 'Stripe' exists is refused for that company alone and reported in `skipped` with the reason — everything else still comes back, and preview_company_merge then merge_companies fold the two together. Returns what was restored, what came back alongside it, and what was skipped and why.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: [...ARCHIVE_KIND_VALUES],
          description: "company | contact | application",
        },
        ids: strArray("Ids of that kind, from list_archive"),
      },
      ["kind", "ids"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      archive.restoreRecords(
        ctx.userId,
        enumArg(args, "kind", ARCHIVE_KIND_VALUES) ?? "company",
        requiredArray(args, "ids"),
      ),
  },
  {
    name: "delete_archived",
    title: "Destroy archived records now",
    description:
      "Destroy archived records now instead of waiting for the retention window. IRREVERSIBLE: no second bin, no undo, no copy. It reaches only records ALREADY in the archive, so nothing can be destroyed in a single step. Destroying a company destroys every application archived with it, timelines and tasks included; a company with a LIVE application is refused. Call list_archive first, tell the person exactly what will go and how many, and get a plain yes before calling this. Usually there is nothing to do here — the archive clears itself — so this is only for something somebody wants gone now.",
    inputSchema: object(
      {
        kind: {
          type: "string",
          enum: [...ARCHIVE_KIND_VALUES],
          description: "company | contact | application",
        },
        ids: strArray("Ids of that kind, from list_archive"),
      },
      ["kind", "ids"],
    ),
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
      openWorldHint: false,
    },
    handler: async (args, ctx) =>
      archive.deleteArchived(
        ctx.userId,
        enumArg(args, "kind", ARCHIVE_KIND_VALUES) ?? "company",
        requiredArray(args, "ids"),
      ),
  },
  {
    name: "empty_archive",
    title: "Empty the archive",
    description:
      "Empty the archive completely, or one kind of it. Everything in it is destroyed immediately and none of it comes back — the most destructive tool on this server, able to take years of applications, timelines and people in one call. Never reach for it because somebody said 'clean up', 'tidy my pipeline' or 'get rid of the old stuff'. It REFUSES unless expectCount matches the archive's count right now: call list_archive, tell the person how many things are about to go and what they are, get a plain yes, and pass back that count; if anything changed in between, the call fails. Use delete_archived for specific things. Returns how many of each kind were destroyed — a company takes the applications archived with it, so this can exceed the rows they saw.",
    inputSchema: object(
      {
        expectCount: num("How many items list_archive just reported. The call fails if it moved."),
        kind: {
          type: "string",
          enum: [...ARCHIVE_KIND_