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; /** * 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; }; /** * 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(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>[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 => 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[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; 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(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( 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(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(input: T): Partial { return Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined)) as Partial; } 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/ 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, })), ); }, }, { 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=\" — 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_VALUES], description: "Only this kind. Omit to empty the whole archive.", }, }, ["expectCount"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, handler: async (args, ctx) => { const kind = enumArg(args, "kind", ARCHIVE_KIND_VALUES); const expected = n(args, "expectCount"); if (expected === undefined) throw new Error('Missing required number argument "expectCount"'); const { total, counts } = await archive.listArchive(ctx.userId, defined({ kind })); const actual = kind ? counts[kind] : total; if (actual !== expected) { throw new Error( `The archive holds ${actual} ${kind ? `${kind} ` : ""}item(s), not ${expected}. Read it back with list_archive and confirm what is about to go.`, ); } return archive.emptyArchive(ctx.userId, defined({ kind })); }, }, // ------------------------------------------------------------------------- // MAIL AND CALENDAR // // Read live from the person's own accounts — Google, Microsoft 365, or any // IMAP and CalDAV provider — never copied here. Every tool in this section // reaches outside the instance, so openWorldHint is true throughout. // ------------------------------------------------------------------------- { name: "list_linked_accounts", title: "Which mail and calendar accounts are connected", description: "Every mailbox and calendar this person has connected for the app to read — Google, Microsoft 365, or an IMAP and CalDAV provider such as Fastmail or iCloud — with which of mail and calendar each provides, its address, when it was last read, and whether it has broken and needs reconnecting (`lastError`). Call this first when a mail or calendar tool fails, or before promising to look something up in their inbox. Google and Microsoft connect through a consent screen in a browser, so they cannot be connected from here: when nothing is listed, tell them to open Settings → Connections in the app and add an account. An IMAP account can be connected with connect_imap_account. Nothing from any inbox is stored on this instance: every read is live, and disconnecting deletes the only thing held, the credential.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => { const accounts = await accountsData.listLinkedAccounts(ctx.userId); return { accounts, connectUrl: `${ctx.baseUrl}/settings?tab=connections`, ...(accounts.length === 0 ? { howToConnect: `Open ${ctx.baseUrl}/settings?tab=connections and add an account. Google and Microsoft 365 ask for read-only access to mail and calendar on a consent screen; any other provider takes an IMAP server and a CalDAV URL with an app password, which connect_imap_account can also do from here.`, } : {}), }; }, }, { name: "connect_imap_account", title: "Connect a mailbox by IMAP and a calendar by CalDAV", description: "Connect any mail provider that is not Google or Microsoft — Fastmail, iCloud, Yahoo, a university or self-hosted server — by its IMAP server and, optionally, a CalDAV URL; either half may be left out. Both are logged in to before anything is saved, so a wrong password fails now. The password must be an APP PASSWORD from the provider's security settings, never the account password — say so before asking for one, and never repeat it back or write it anywhere. Connecting an address already connected replaces its details. Presets: Fastmail imap.fastmail.com, CalDAV https://caldav.fastmail.com/; iCloud imap.mail.me.com, CalDAV https://caldav.icloud.com/; Yahoo imap.mail.yahoo.com. Read-only access: the app can never send, move or delete anything with it.", inputSchema: object( { email: str("The address of the mailbox."), label: str("What to call it on the tile, e.g. 'Work' or 'Old university address'. Optional."), imapHost: str("IMAP server, e.g. imap.fastmail.com. Leave out for a calendar-only account."), imapPort: num("IMAP port. Default 993 (TLS)."), imapUsername: str("IMAP username. Defaults to the address."), imapPassword: str("An app password for IMAP. Never the account password."), caldavUrl: str("CalDAV server or calendar-home URL, e.g. https://caldav.fastmail.com/. Leave out for a mail-only account."), caldavUsername: str("CalDAV username. Defaults to the IMAP username, then the address."), caldavPassword: str("An app password for CalDAV. Defaults to the IMAP password — many providers use one for both."), }, ["email"], ), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => accountsData.connectImapAccount(ctx.userId, { email: required(args, "email"), ...defined({ label: s(args, "label"), imapHost: s(args, "imapHost"), imapPort: n(args, "imapPort"), imapUsername: s(args, "imapUsername"), imapPassword: s(args, "imapPassword"), caldavUrl: s(args, "caldavUrl"), caldavUsername: s(args, "caldavUsername"), caldavPassword: s(args, "caldavPassword"), }), }), }, { name: "test_linked_account", title: "Test a connected account", description: "Read one thing from each half of a connected account — the most recent mail, the events around today — and report whether it answered, with the provider's own words when it did not. The way to find out whether 'no threads' means an empty result or a dead connection. Get the id from list_linked_accounts. Records the outcome on the account (`lastError`), which is why it is not marked read-only.", inputSchema: object({ accountId: str("The account id from list_linked_accounts.") }, ["accountId"]), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => accountsData.testAccount(ctx.userId, required(args, "accountId")), }, { name: "disconnect_account", title: "Disconnect a mail or calendar account", description: "Forget one connected account: revoke this instance's access where the provider allows it (Google) and delete the credential. Every read from that account stops immediately; nothing else — no contact, application or logged activity — is touched, because nothing from it was ever stored. Other accounts stay connected. Confirm before calling it. Get the id from list_linked_accounts; reconnecting is the same consent screen or form as the first time, under Settings → Connections.", inputSchema: object({ accountId: str("The account id from list_linked_accounts.") }, ["accountId"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => accountsData.disconnectAccount(ctx.userId, required(args, "accountId")), }, { name: "list_correspondence", title: "Mail and meetings about one record", description: "Every email thread and calendar event, across all their connected accounts, involving one thing on the pipeline — pass exactly one of contactId, companyId, applicationId or resumeId; each parameter says what it matches. The tool for 'what's the latest with Stripe', 'have I heard back from Jane', 'when is my interview' — call it before summarising where an application stands, because the timeline only knows what was logged by hand. Returns `mail` (threads newest first, with subject, snippet, participants, source `account` and a link) and `calendar` (past and upcoming events with attendees and links); either is null when no account provides it or all refused, with the reason in `warnings`. `notes` explains a thin result — usually a contact with no email or a company with no website; fix those with update_contact or update_company and call again. Nothing is saved. get_email_thread reads a thread in full; log_activity records what you learned.", inputSchema: object({ contactId: str("A contact id. Matches their email address."), companyId: str("A company id. Matches its website's domain and the addresses of its people."), applicationId: str("An application id. Matches the company's domain and the people attached to this application."), resumeId: str("A resume id. Matches every application the resume is attached to."), limit: num("How many threads to return at most. Default 20, maximum 50."), days: num("How far back to look, in days. Default 365. Calendar events up to 120 days ahead are always included."), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => { const subject = correspondenceSubject(args); return accountsData.listCorrespondence(ctx.userId, subject, { ...defined({ limit: n(args, "limit"), days: n(args, "days") }), }); }, }, { name: "search_email", title: "Search mail", description: "Search every connected mailbox, or one, for free text — 'take-home', 'phone screen', a recruiter's name; on Gmail its operators work too (`from:jane@acme.com`, `subject:offer newer_than:7d`). Reach for this when the mail does not map to one record: 'did any rejections come in this week', 'find the take-home email'. For a specific contact, company or application, list_correspondence builds the right query. Returns threads newest first across accounts, each with subject, a snippet of the latest message (empty on IMAP), participants, when it last moved, its `account` and a link where the provider has one. Subjects and snippets only — get_email_thread reads the messages. `warnings` names any account that did not answer. Read-only: nothing is saved, and it cannot send, archive or delete anything.", inputSchema: object( { query: str("Words to search for. Gmail operators pass through on a Gmail account."), limit: num("How many threads at most. Default 20, maximum 50."), accountId: str("Search one account only. Omit for all of them."), }, ["query"], ), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => accountsData.searchEmail(ctx.userId, { query: required(args, "query"), ...defined({ limit: n(args, "limit"), accountId: s(args, "accountId") }), }), }, { name: "get_email_thread", title: "Read an email thread", description: "One thread in full, oldest message first: who sent each message, to whom, when, and the body as plain text (HTML mail is stripped to text; attachments are never fetched; very long messages are cut). The id comes from list_correspondence or search_email and already says which account it lives in. This is how you find out what a recruiter actually wrote — the dates they proposed, the salary they named, the next step they described — before logging it with log_activity or moving the application with move_application_stage. Quote the mail when you report it; do not paraphrase a number. Read-only, and nothing about the thread changes: it is not marked read.", inputSchema: object({ threadId: str("The thread id from list_correspondence or search_email.") }, ["threadId"]), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => accountsData.getEmailThread(ctx.userId, required(args, "threadId")), }, { name: "get_mail_sweep", title: "Is the mail sweep on", description: "Whether this person has asked the app to look through their own mail on a schedule, when it last ran, how far it has read up to, and whether a mailbox is actually connected for it to read. `mailConnected` false means the switch is inert until they connect something under Settings → Connections — say that rather than turning on something that cannot work. `note` is non-empty when the last run was short, usually because the window came back full. A non-empty `scheduleNote` means nothing runs the sweep on a schedule on this instance, so it only happens when run_mail_sweep is called. Read-only, and it reads nothing from anybody's mailbox.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => mailSweep.getMailSweep(ctx.userId), }, { name: "set_mail_sweep", title: "Turn the mail sweep on or off", description: "Turns the mail sweep on or off — OFF until somebody asks, and this is the only thing that turns it on. On, the app reads mail arriving from the companies and people already on their pipeline and queues what it finds on their dashboard: a message to log, a person to add, a meeting meaning an interview. IT NEVER WRITES TO THE PIPELINE — every finding waits for a yes — and it reads nothing outside the addresses and domains their records name. It also reads a connected calendar, where the one stage move comes from; say that when you offer it. Only offer it when asked; nobody wants an assistant signing them up to have their inbox read. Turning it off leaves everything already queued in place. Returns the settings as they stand; read back any `scheduleNote`, which means the instance runs no schedule for it yet.", inputSchema: object({ on: bool("True to sweep, false to stop") }, ["on"]), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => mailSweep.setMailSweep(ctx.userId, args.on === true), }, { name: "run_mail_sweep", title: "Read what is new in the mail now", description: "Read this person's own mailboxes for messages from the companies and people on their pipeline since the last look, and queue what a rule can prove: a message to log against a job, somebody new at a tracked company to add, and — with a calendar connected — a booked meeting meaning an application reached interviewing. NOTHING IS WRITTEN: every finding is a proposal on their dashboard, applied by accept_proposal, and you never accept your own proposals. It does NOT judge what a message MEANS: a rejection, an offer, a take-home or a reschedule comes back in `needsReading` with its thread id. Work through those next — get_email_thread on each, then propose_changes quoting the line you read as evidence. Returns the window read, threads seen, what was queued, what needs reading, and any account that refused. Pass `days` to look further back than the last sweep, which is what to do the first time. Safe alongside the hourly schedule: a thread already accounted for is never queued twice.", inputSchema: object({ days: num( "Look back this many days instead of picking up where the last sweep stopped. Use it for a deliberate wider look", ), include_calendar: bool( "Also look at booked meetings, which is where the one stage move comes from. Default true when a calendar is connected", ), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, handler: async (args, ctx) => mailSweep.runMailSweep( ctx.userId, defined({ days: n(args, "days"), includeCalendar: b(args, "include_calendar") }), ), }, { name: "search_calendar", title: "Search calendars", description: "Events across every connected calendar, or one of them, in a window, optionally filtered by words matched against title, description, location and attendee names and addresses. Use it for 'what interviews do I have this week', 'when did I last meet anyone from Acme' or 'am I free Thursday afternoon' — for a whole week of the pipeline's own dates alongside these meetings, list_schedule merges both. Defaults to thirty days back and sixty ahead. Each event has its title, start and end, whether it is all-day, the attendees with their RSVP, the organizer, a meeting link when there is one, which `account` it is on, and a link to the event where the provider has one. `warnings` names any account that did not answer. Read-only; nothing here creates, accepts or declines anything.", inputSchema: object({ query: str("Words to match against title, description, location and attendees. Omit for every event in the window."), from: str("Start of the window, ISO date (YYYY-MM-DD). Default: 30 days ago."), to: str("End of the window, ISO date (YYYY-MM-DD), inclusive. Default: 60 days ahead."), limit: num("How many events at most. Default 100."), accountId: str("Search one account only. Omit for all of them."), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, handler: async (args, ctx) => { const zone = await me.timeZoneOf(ctx.userId); return accountsData.searchCalendar(ctx.userId, { ...defined({ query: s(args, "query"), from: s(args, "from") ? startOfDay(zone, required(args, "from")) : undefined, to: s(args, "to") ? endOfDay(zone, required(args, "to")) : undefined, limit: n(args, "limit"), accountId: s(args, "accountId"), }), }); }, }, // ------------------------------------------------------------------------- // ACCOUNT // ------------------------------------------------------------------------- { name: "get_digest_settings", title: "Check the two emails this app can send", description: "Whether this person has asked for a weekly summary or a due-today nudge, what hour they go out at in their own zone, when each was last sent, and whether the instance can send mail at all. `emailConfigured` false means both switches are inert until an admin sets up email — say that rather than turning something on that cannot work. A non-empty `scheduleNote` means nothing calls this instance's digest address on a schedule, so nothing goes out on its own either way: read it back. Read-only.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => digest.getDigestPreferences(ctx.userId), }, { name: "set_digest_settings", title: "Turn the weekly summary or the daily nudge on or off", description: "Turns the three digest emails on or off — all OFF until somebody asks, and this is the only thing that turns them on. Weekly arrives Monday morning with where the search stands. Daily arrives only on a day something is due, and sends nothing on a quiet day. MONTHLY arrives on the last day of the month asking for one thing that went well, so Me keeps growing after the search — offer it the day somebody accepts an offer, which is the day the other two should come off. It stops after three unanswered; logging a win, or switching it off and on, restarts the count. `hour` is in their own time zone. Only offer any of this when somebody asks for mail. Returns the settings as they now stand; a non-empty `scheduleNote` means nothing will send until the instance's scheduler is set up — say so.", inputSchema: object({ weekly: bool("The Monday summary"), daily: bool("The due-today nudge. Only sends on a day something is due"), monthly_wins: bool( "The end-of-month ask for one thing that went well. For after the search, not during it.", ), hour: num("What hour they go out at, 0-23, in their own zone. Default 8"), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => digest.setDigestPreferences(ctx.userId, { weeklyDigest: b(args, "weekly"), dailyNudge: b(args, "daily"), winsNudge: b(args, "monthly_wins"), digestHour: n(args, "hour"), }), }, { name: "preview_digest", title: "See what a digest would say", description: "Build the weekly summary, the due-today nudge or the monthly wins ask and return it WITHOUT sending anything. Reach for it beside set_digest_settings, to show somebody what turning one on would send them. For 'what does my week look like' use list_schedule, and for 'what first today' use morning_brief. Returns the subject, the opening line and the sections. `empty` true on a nudge means nothing is due, and on a wins ask means there is no current role for a win to land on — which is why no mail would go out in either case.", inputSchema: object({ kind: { type: "string", enum: [...DIGEST_KINDS], description: "weekly (the Monday summary), nudge (what is due today) or wins (the end-of-month ask). Default weekly.", }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => digest.digestContent( ctx.userId, (enumArg(args, "kind", DIGEST_KINDS) as digest.DigestKind | undefined) ?? "weekly", ), }, { name: "send_digest_now", title: "Send one of the two emails now", description: "Actually send the weekly summary or the daily nudge to this person's own address, immediately, ignoring the schedule and the once-a-day guard. It does NOT ignore the opt-in: somebody who has not turned that message on gets nothing and the result says so. Use it to show someone what they signed up for, or when they ask for their week by mail. For reading it here, preview_digest is the tool — this one puts a message in an inbox.", inputSchema: object({ kind: { type: "string", enum: [...DIGEST_KINDS], description: "weekly, nudge or wins. Default weekly.", }, }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, handler: async (args, ctx) => digest.sendDigest( ctx.userId, (enumArg(args, "kind", DIGEST_KINDS) as digest.DigestKind | undefined) ?? "weekly", { force: true }, ), }, { name: "export_everything", title: "Export the whole workspace", description: "Every record this person owns as one JSON document that import_everything takes back — Me, resumes, letters, the whole pipeline with timeline, tasks, offers, tags and saved views. The answer to 'can I get my data out'. Five things are left out, and the result names them: connection tokens and mail passwords (a backup must never carry a credential), published slugs, the review queue, anything belonging to the instance, and their profile photo. `counts` says how many of each came out — read it back rather than claiming success blindly. If the file is too large to hand around, Settings → Account → Download everything saves the same file from the browser. Read-only.", inputSchema: object({ counts_only: bool( "Return just the counts and what was excluded, with no records. Use it to check what a backup would contain", ), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => { const doc = await transfer.exportWorkspace(ctx.userId); return b(args, "counts_only") ? { ...doc, data: {}, profile: null } : doc; }, }, { name: "import_everything", title: "Put a workspace export back", description: "Restore a document written by export_everything. ADDITIVE AND MATCHED BY NATURAL KEY: a role is matched by employer and title, a company by name, a job by employer and role, a tag by kind and name. What is already here is left exactly as it is and only what is missing gets written, so importing the same file twice does nothing the second time and importing into a workspace that already has material cannot overwrite it. It is a restore, not a replacement — there is no mode that empties anything first, deliberately. ALWAYS run it with dry_run true first on a file you did not just write, and read the report back: `created` and `skipped` per area, plus `problems` naming anything that could not be placed. A file from a different version is refused rather than half-read.", inputSchema: object( { document: { type: "object", description: "The whole export document, exactly as export_everything returned it", additionalProperties: true, }, dry_run: bool("Do every lookup and no writes, and report the same numbers. Do this first"), }, ["document"], ), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => transfer.importWorkspace( ctx.userId, (args.document ?? {}) as transfer.WorkspaceExport, { dryRun: b(args, "dry_run") ?? false }, ), }, { name: "whoami", title: "Who am I connected as", description: "The account this connection belongs to — name, email, role, whether it can administer the instance — what this connection is called and what it is served (`connection.scope`), and how far into setup the workspace is. Every other tool acts as this person and can only see their data. Call it when someone new asks what to do first, or when a read comes back empty and you are deciding whether that is an empty account or a wrong query: `setup` says which of the three things a workspace needs (a job on the board, career material in Me, something connected) are done and what each is waiting for. `tourSeenAt` null means they have never been shown around the web app — explain things rather than assuming they know what a pipeline stage is.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => { const { tourSeenAt, ...setup } = await onboarding.setupStatus(ctx.userId); return { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email, role: ctx.user.role, isAdmin: isAdmin(ctx.user), memberSince: ctx.user.createdAt, connection: { name: ctx.connectionName, scope: ctx.scope }, setup, tourSeenAt, }; }, }, { name: "load_sample_workspace", title: "Fill an empty workspace with a sample search", description: "Write a small, plausible job search into this workspace to look at and practise on: four employers, six applications across every stage including one lost, five people, four weeks of timeline, three tasks (one overdue), two letters, a resume, and an offer revised a week later for eighteen thousand more. Reach for this when somebody says the app looks empty, or when showing how it works before they have anything real — a better first minute than a tour of blank screens. It is ORDINARY DATA, so it moves, filters, exports and archives like their own. It refuses on a workspace that already has jobs, roles or highlights, because a sample mixed into a real search is noise nobody can separate; if they want it anyway, say what it will add and pass allow_anyway true. wipe_sample_workspace removes only what this made, keeping and naming anything they have edited or added to. Returns what was created and where to look first.", inputSchema: object({ allow_anyway: bool( "Load it even though this workspace already has real material in it. Say what it will add first", ), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, handler: async (args, ctx) => sample.loadSampleWorkspace(ctx.userId, { allowAnyway: b(args, "allow_anyway") === true }), }, { name: "wipe_sample_workspace", title: "Remove the sample search", description: "Take the sample back out. Removes ONLY what load_sample_workspace wrote, from the list it recorded; anything the person has since edited or added to is KEPT, named in the report and left in place, as is a sample company still holding an application they kept. What it deletes is permanent — nothing goes to the archive — so say roughly what will go and get a plain yes. Returns what was deleted, what was kept and WHY, and `complete`: false means pieces are still held and the report names each, so read it back rather than reporting success. Calling it again picks up where it stopped.", inputSchema: object({}), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => sample.wipeSampleWorkspace(ctx.userId), }, { name: "restart_tour", title: "Show the welcome tour again", description: "Queue the web app's welcome tour to run again the next time this person opens it — the short walk through what the board, Me and the assistant connection are for. Reach for it when somebody says they are lost, that they never saw an introduction, or that they want the tutorial back; also worth offering to someone whose `whoami` shows tourSeenAt null who is clearly struggling. It does not open anything on its own — nothing here can reach into their browser — so say plainly that it will appear next time they load the app. Setting it back to seen is the tour\'s own job, not a tool: they put it away by finishing or closing it.", inputSchema: object({}), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => { await onboarding.setTourSeen(ctx.userId, false); return { queued: true, message: "The welcome tour will run the next time they open the web app.", }; }, }, { name: "list_changes", title: "What each assistant changed", description: "Every write that arrived over MCP, newest first: which connection, which tool, what it touched, and a line saying so. Reach for it when somebody asks what an assistant actually did, when something looks wrong and nobody remembers changing it, or before restore_revision with change_id so you can name the change. Saves made in the app's own screens are NOT listed, though they are still versioned. `undoable` and `restorePointAt` matter most: only writes replacing a resume or a role are versioned, and `restorePointAt` — where the record would go back to — is often EARLIER than the change, because versions coalesce within ten minutes per author. Say that time out loud before undoing ('back to 14:02, before three edits') and get a yes. Read-only.", inputSchema: object({ connection_id: str("Only changes made through this connection, from list_connections"), tool: str("Only calls of this tool, e.g. 'update_resume'"), kind: str("Only changes to this sort of thing: resume, role, application, contact, offer…"), record_id: str("Only changes to this one record"), limit: num("How many to return. Default 50, ceiling 500."), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => revisions.listChanges( ctx.userId, defined({ connectionId: s(args, "connection_id"), tool: s(args, "tool"), kind: s(args, "kind"), recordId: s(args, "record_id"), limit: n(args, "limit"), }), ), }, { name: "list_revisions", title: "Earlier versions of a resume or a role", description: "Every stored version of one resume or one role, newest first, with when it was taken and who caused it. Each row is a BEFORE image — the record as it was before a write replaced it — so the newest row is where an undo lands. Reach for it before update_resume or update_role on something that matters, to tell them what they could get back to, and after a write that went wrong. Versions are taken when the whole record is replaced, and when a role's background is appended to — not for a font or a rename — and COALESCED to one per record per author per ten minutes, keeping the older image. At most twenty are kept per record and old ones are swept, so this is a safety net, not an archive. `recordExists` false means the record was deleted and nothing can be restored to it. For a role, each row's `changedSince` lists the background lines added and removed since — exactly what restoring would undo; read those out first. Read-only.", inputSchema: object( { kind: { type: "string", enum: ["RESUME", "ROLE"], description: "Which sort of record. These are the only two that are versioned.", }, record_id: str("The resume id or the role id"), limit: num("How many versions to return. Default and ceiling 20."), }, ["kind", "record_id"], ), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => { const kind = required(args, "kind") as "RESUME" | "ROLE"; if (kind === "ROLE") { const history = await revisions.roleHistory(ctx.userId, required(args, "record_id")); const limit = n(args, "limit"); return limit ? { ...history, rows: history.rows.slice(0, Math.max(1, limit)) } : history; } return revisions.listRevisions(ctx.userId, kind, required(args, "record_id"), n(args, "limit")); }, }, { name: "restore_revision", title: "Put a stored version back", description: "Replace a resume's document, or a role's fields, with an earlier stored version — or undo one change. Pass `id` (from list_revisions) when they can name the VERSION, or `change_id` (from list_changes, only writes it marks undoable) when they can name the CHANGE; it restores the version from just before that write. A deleted company, person or application comes back from the archive with restore_records; a deleted role or resume cannot come back at all. SAY WHAT WILL HAPPEN AND GET A YES FIRST: this replaces the current state, and because versions are coalesced within ten minutes per author, it can go back past several edits. The restore is itself undoable — the result names `redoRevisionId`. For a resume only the DOCUMENT comes back: template, font, size, margins and any published link stay as they are. `changed` false means the record already matched; say so rather than reporting work that did not happen. Refuses, writing nothing, when the stored version does not parse or the record is gone.", inputSchema: object({ id: str("Revision id, from list_revisions"), change_id: str("Or a change id, from list_changes: undo that one write"), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, versioned: true, handler: async (args, ctx) => { const author = mcpAuthor(ctx, "restore_revision"); const change = s(args, "change_id"); if (change) return revisions.undoChange(ctx.userId, change, author); return revisions.restoreRevision(ctx.userId, required(args, "id"), author); }, }, { name: "list_connections", title: "List AI connections", description: "Every assistant wired to this workspace: what it is called, which client it was set up for, when it last called in and from what. Reach for it to answer 'which of these am I still using?' or before rotating something — the ids come back here. Tokens deliberately do not: they are credentials, they would sit in this transcript forever, and the only place a person needs to see one is the client they are pasting it into. `isThisOne` marks the connection you are calling through right now, and `scope` says what each one is SERVED — FULL is everything, and a narrowed one is offered fewer tools without being able to do less to the account. update_connection changes it.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => { const rows = await connections.listConnections(ctx.userId); return rows.map((row) => ({ id: row.id, name: row.name, client: row.client, clientName: clientName(row.client), createdAt: row.createdAt, lastUsedAt: row.lastUsedAt, lastUsedFrom: guessClient(row.lastUsedFrom) || null, scope: row.scope, scopeLabel: scopeLabel(row.scope), isThisOne: row.id === ctx.connectionId, })); }, }, { name: "create_connection", title: "Connect another assistant", description: "Mint a new connection URL so a second client — the laptop's editor, a phone app, a terminal — can reach this workspace too. Returns the URL and the exact steps for that client, so you can hand somebody a copy-paste answer to 'how do I add this to Cursor?'. Give every client its own rather than sharing one: that is what lets a single laptop be disconnected later without breaking everything else. The URL is a credential with full read and write over this person's career history, resumes and pipeline — say so when you hand it over, and never post it anywhere it will be stored.", inputSchema: object({ client: str( "Which client it is for: claude | claude-code | chatgpt | cursor | vscode | windsurf | generic-http | stdio-bridge | raw. Sets the setup steps returned.", ), name: str("What to call it in the list, e.g. 'Cursor — work laptop'. Defaults to the client's name."), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, handler: async (args, ctx) => { const client = s(args, "client") ?? "generic-http"; const row = await connections.createConnection(ctx.userId, { client, ...defined({ name: s(args, "name") }), }); const url = `${ctx.baseUrl}/api/mcp/${row.token}`; const recipe = clientsById.get(row.client); return { id: row.id, name: row.name, client: row.client, url, setup: recipe?.steps(url) ?? [], docs: recipe?.docs ?? null, warning: "This URL is a password. Anyone holding it can read and write this workspace.", }; }, }, { name: "update_connection", title: "Rename a connection, or narrow what it is served", description: "Rename one connection, change what it is served, or both. `name` is for the person — 'Cursor — old laptop' makes it obvious later which one to revoke. `scope` sets which tools that client is offered: FULL is everything; WRITING is Me, resumes and letters plus the four reads a document needs about its job; PIPELINE is the search — applications, people, the archive, mail and calendar; READONLY is every tool that writes nothing. Narrowing is a ROUTING AID, NOT A PERMISSION: the URL still reaches the whole account, anyone who can sign in can widen it again, and it is never a reason to hand a connection URL to somebody you would not hand the account to. What it buys is accuracy — a client choosing from sixty tools picks wrong less often than one choosing from two hundred. It applies on the client's next call; one that caches its tool list may need reconnecting. Say which connection and which scope before calling, because a narrowed client silently loses abilities its person may be relying on. Ids come from list_connections, which shows each one's current scope.", inputSchema: object( { id: str("Connection id, from list_connections"), name: str("The new name"), scope: { type: "string", enum: [...SCOPE_VALUES], description: "FULL, WRITING, PIPELINE or READONLY" }, }, ["id"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => { const id = required(args, "id"); const name = s(args, "name"); const scope = s(args, "scope") as McpScope | undefined; if (name === undefined && scope === undefined) throw new Error("Pass a name, a scope, or both."); if (scope !== undefined && !(SCOPE_VALUES as readonly string[]).includes(scope)) { throw new Error(`scope must be one of ${SCOPE_VALUES.join(", ")}.`); } if (name !== undefined) await connections.renameConnection(ctx.userId, id, name); if (scope !== undefined) await connections.setConnectionScope(ctx.userId, id, scope); return { id, ...(name !== undefined ? { name } : {}), ...(scope !== undefined ? { scope, label: scopeLabel(scope), serves: scopeBlurb(scope) } : {}), }; }, }, { name: "rotate_connection", title: "Issue a new token for a connection", description: "Kill a connection's URL and issue a fresh one on the same row, keeping its name and place in the list. This is the answer to 'I sold that laptop' or 'I pasted the URL in a chat by mistake' — the old address stops working the moment this returns, and nothing else is affected. It also means the client that was using it goes dark until somebody pastes the new URL in, so say that before doing it. Returns the new URL; treat it like the password it is.", inputSchema: object({ id: str("Connection id, from list_connections") }, ["id"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, handler: async (args, ctx) => { const id = required(args, "id"); const token = await connections.rotateConnection(ctx.userId, id); return { id, url: `${ctx.baseUrl}/api/mcp/${token}`, warning: "The old URL is dead. Paste this one into that client or it stays disconnected. It is a password.", }; }, }, { name: "get_capture_link", title: "The link that captures a posting from a phone", description: "The private address that turns a job posting into an application without opening the app, plus the bookmarklet built around it — the answer to 'I find jobs on my phone'. Tap the bookmark on a posting and the job lands on the wishlist. Returns the URL, the bookmarklet, how many postings it has captured and when it was last used — or `exists` false when none has been minted, the default. THE URL IS A CREDENTIAL: anyone holding it can add applications here. It can read nothing, but never repeat it anywhere it will be stored; they can copy it from Settings → Connections themselves. Read-only.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, handler: async (_args, ctx) => captureLink.getCaptureLink(ctx.userId, ctx.baseUrl), }, { name: "set_capture_link", title: "Mint, rotate or revoke the capture link", description: "Create the capture link, replace it, or switch it off. Minting hands back the URL and the bookmarklet; MINTING WHEN ONE ALREADY EXISTS ROTATES IT, which kills the old URL immediately — every bookmark and Shortcut built on it stops working and has to be replaced, so only do it when they say the link has leaked or they want a new one. `off` true revokes it: the address goes back to answering 404 for everybody, and nothing already captured is touched. Tell them which of the three you are about to do, in those words, before you call it. The link is capped at thirty captures an hour, which is generous for a person and useless to anyone who found it, and it should never be pasted anywhere that unfurls links — a preview bot following it would fire it.", inputSchema: object({ off: bool("Revoke the link instead of minting one. Nothing already captured is affected"), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, handler: async (args, ctx) => b(args, "off") ? captureLink.revokeCaptureLink(ctx.userId) : captureLink.mintCaptureLink(ctx.userId, ctx.baseUrl), }, { name: "delete_connection", title: "Disconnect an assistant", description: "Remove a connection for good. Its URL stops working immediately and cannot be brought back — a replacement is a new connection with a new URL. Prefer rotate_connection when the client is still in use and only the URL has leaked. Check list_connections first: deleting the one you are calling through ends this session mid-conversation.", inputSchema: object({ id: str("Connection id, from list_connections") }, ["id"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, handler: async (args, ctx) => { const id = required(args, "id"); if (id === ctx.connectionId) { throw new Error( "That is the connection you are talking through. Delete it from Settings, or delete a different one.", ); } await connections.deleteConnection(ctx.userId, id); return { id, deleted: true }; }, }, // ------------------------------------------------------------------------- // ADMIN — hidden entirely from members // ------------------------------------------------------------------------- { name: "admin_instance_stats", title: "Instance overview", description: "How many people are on this instance, how many are active, how many invites are outstanding, and how much material exists across all accounts. Aggregate counts only — never another person's content.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => { const [stats, settings] = await Promise.all([users.instanceStats(), getSettings()]); return { ...stats, instanceName: settings.instanceName, companyLogos: settings.companyLogos }; }, }, { name: "admin_list_users", title: "List members", description: "Everyone on the instance with their role, whether they are active, when they last signed in, and how much they have built. Does not expose anyone's career history, resumes or applications.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => users.listUsers(), }, { name: "admin_user_detail", title: "Look up one account", description: "Everything known about a single account, for when someone asks for help: when they joined, who invited them, whether that invitation email actually went out, when they last signed in, which assistants they have connected and when each last called, whether they are being billed, how much they have built, every administrative change made to their account, and anything the instance recorded against their address — a bounced invite, a tool call that threw. Start here before admin_reset_user_password or admin_set_user_active, because it tells you whether the problem is the account or the email. Takes a user id from admin_list_users. Returns counts of what is in their workspace, never its contents: no career history, no resumes, no applications, and never a connection token. `manageable` says whether you are allowed to act on this account at all — it is false for the instance owner, for yourself, and for another admin when you are not the owner.", inputSchema: object({ user_id: str("The user's id, from admin_list_users") }, ["user_id"]), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { const id = required(args, "user_id"); const detail = await users.getUserDetail(ctx.user, id); if (!detail) throw new Error("No such user."); const [history, events] = await Promise.all([ audit.listAudit({ targetId: id, limit: 50 }), system.listSystemEvents({ limit: 25, userEmail: detail.email }), ]); return { ...detail, history, events }; }, }, { name: "admin_invite_user", title: "Invite someone", description: "Create an invitation and email it through Resend. If email is not configured yet, the invite is still created and the reply includes a link you can send by hand — so this works before Resend is set up. Normally the invitee picks their own password on the accept page and you never see it. Pass `password` instead to choose it for them: the accept page then asks only for their name, and you have to tell them the password some other way, because it is deliberately never put in the invitation email — a message carrying both the link and the password it opens IS the account, sent to an address nobody has proven yet. Pass `must_change_password` alongside it when you would rather not keep a way in: they replace your password the moment they are through the door. The password is hashed immediately and never comes back out of any tool.", inputSchema: object( { email: str("Who to invite"), role: { type: "string", enum: ["MEMBER", "ADMIN"], description: "MEMBER by default. Only the super admin may create ADMINs.", }, password: str( "Set their password yourself instead of letting them pick one. At least 10 characters. Omit for the normal flow, where the accept page asks them for one.", ), must_change_password: bool( "Whether they must replace your password with their own before the app opens to them. Off unless you ask for it, and ignored without a password.", ), }, ["email"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, adminOnly: true, handler: async (args, ctx) => { const result = await users.createInvite({ actor: ctx.user, email: required(args, "email"), role: (s(args, "role") as UserRole | undefined) ?? "MEMBER", baseUrl: ctx.baseUrl, // Not run through defined(): createInvite reads undefined as "not // asked for" already, and defined() would make every key optional and // take `actor` with it. password: s(args, "password"), mustChangePassword: b(args, "must_change_password"), }); return { email: result.invite.email, expiresAt: result.invite.expiresAt, emailSent: result.emailSent, emailError: result.emailError, acceptUrl: result.acceptUrl, passwordSet: result.passwordSet, mustChangePassword: result.mustChangePassword, note: [ result.emailSent ? "Invitation emailed." : "Invitation created but NOT emailed — send them the acceptUrl yourself, or configure Resend with admin_set_email_config.", result.passwordSet ? "The password is not in that email. Tell them separately, or they cannot sign in." : "", ] .filter(Boolean) .join(" "), }; }, }, { name: "admin_list_invites", title: "List outstanding invites", description: "Invitations that have not been accepted yet, with their links, expiry, and whether a password was set on each. Never returns the password itself — `passwordSet` is a boolean, and the stored hash does not leave the server.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => users.listInvites(), }, { name: "admin_set_invite_password", title: "Set the password on an invitation", description: "Put a password on an invitation that has already gone out, or take one off, without changing its link. Reach for this when you invited somebody the normal way and then decided to hand them a password — re-inviting the same address would work too, but it mints a fresh token and the link you already sent stops working, which is the opposite of what you want here. Pass `password` to set one, at which point the accept page asks only for their name; pass an empty string to clear it and put them back to choosing their own. `must_change_password` makes them replace your password before the app opens to them, and is off unless you ask for it. The password never reaches the invitation email that already went out, so tell them yourself. Only works while the invitation is outstanding: once it has been accepted, use admin_reset_user_password on their account instead, which also ends their sessions. Takes an invite id from admin_list_invites.", inputSchema: object( { id: str("Invite id, from admin_list_invites"), password: str( "The password to set, at least 10 characters. An empty string removes the one that is there and lets them pick their own again.", ), must_change_password: bool( "Whether they must replace your password with their own before the app opens to them. Off unless you ask for it, and ignored when clearing.", ), }, ["id"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.setInvitePassword( ctx.user, required(args, "id"), // Not defined(): an empty password is a real instruction here — it // clears the one on the invitation — so it must survive as "". { password: s(args, "password"), mustChange: b(args, "must_change_password") }, ), }, { name: "admin_revoke_invite", title: "Revoke an invite", description: "Cancel an outstanding invitation. The link stops working immediately and cannot be revived — inviting the same address again mints a new token and a new link. Only touches invitations that have not been accepted; once somebody is through the door, use admin_set_user_active to suspend them or admin_delete_user to remove them. Takes an id from admin_list_invites.", inputSchema: object({ id: str("Invite id") }, ["id"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.revokeInvite(ctx.user, required(args, "id")), }, { name: "admin_reset_user_password", title: "Reset a member's password", description: "Give a locked-out member a working password again, and return it once so it can be passed on. With no `password` it generates a passphrase, which is the safe default; pass one to set a password you chose. Either way every session they had is ended, so an old browser stays logged out. Either way you now know a password that opens their workspace, so `must_change_password` is there to close the app to them until they set their own. It is off unless you ask for it. Cannot be used on the instance owner, and an admin cannot reset another admin's password: that restriction is what stops this being a way to take over an instance. The reset is written to the audit log; the password itself never is.", inputSchema: object( { user_id: str("The user's id, from admin_list_users"), password: str( "Set this exact password instead of generating one. At least 10 characters. Omit to generate a passphrase.", ), must_change_password: bool( "Whether they must set their own password before the app opens to them again. Off unless you ask for it.", ), }, ["user_id"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.adminResetPassword( ctx.user, required(args, "user_id"), defined({ password: s(args, "password"), mustChange: b(args, "must_change_password") }), ), }, { name: "admin_audit_log", title: "Read the admin audit log", description: "What admins have done on this instance, newest first: invitations, role changes, suspensions, deletions, password resets, billing links and changes to the instance's own configuration, each with who did it, to whom, and when. Rows survive the deletion of the account they describe. Use it to answer 'who suspended this person', 'who changed the Resend key', or to review what happened while you were away. Narrow with group (accounts, invites, passwords, billing, settings) and search, which matches either side of a row — the admin who acted or the account acted on — and page with offset. Nothing here touches anyone's career history, resumes or applications, and a secret is recorded as having been set, never as its value.", inputSchema: object({ limit: num("How many entries, newest first. Default 100, max 500."), offset: num("Skip this many before returning, for paging through a long log."), group: { type: "string", enum: ["accounts", "invites", "passwords", "billing", "settings"], description: "Only one kind of change. Omit for everything.", }, search: str("An email address, whole or partial. Matches the admin who acted or the account acted on."), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args) => audit.listAudit( defined({ limit: n(args, "limit") ?? 100, offset: n(args, "offset"), group: s(args, "group"), search: s(args, "search"), }), ), }, { name: "admin_health", title: "Check whether the instance is working", description: "This is the FIRST tool to call when something is reported broken, and the one to call on a schedule if you check on this instance at all. Returns a short list of checks — database reachability and response time, whether every migration finished, whether email is configured and whether the last send actually succeeded, whether Stripe is still calling the webhook, when an assistant last made a tool call, and how many errors were recorded in the last 24 hours. Each check has a status of ok, warn or down plus a plain-language summary you can read out as-is. Nothing here touches anyone's career history, resumes or applications. A 'down' on billing usually means the signing secret in Admin → Configuration → Billing is wrong; a billing check that says Stripe has never called means the webhook endpoint was never added on Stripe's side. Follow up with admin_recent_errors for the specifics behind an error count.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => system.instanceHealth(), }, { name: "admin_recent_errors", title: "Read what has failed recently", description: "The instance's own event stream, newest first: failed emails, Stripe webhooks that did not verify or did not sync, reads of somebody's connected mail and calendar, MCP requests refused for naming a browser origin this instance does not allow, tool calls that threw, and pages that errored. Use it after admin_health reports errors, or to answer 'did that invite actually send'. Each entry has a level (INFO, WARN or ERROR), a source, a one-line message, and the address of whoever's request hit it. Pass level ERROR for failures only — the default includes INFO entries such as successful webhook deliveries, which are what prove Stripe is still reaching this instance at all. Entries older than 30 days are removed automatically. This never contains anyone's content: the arguments that caused a failure are deliberately not recorded, only the failure.", inputSchema: object({ limit: num("How many entries, newest first. Default 50, max 200."), level: { type: "string", enum: ["INFO", "WARN", "ERROR"], description: "Only entries at this level. Omit for everything.", }, source: { type: "string", enum: [...SYSTEM_EVENT_SOURCES], description: "Only entries from this part of the app. Omit for everything.", }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args) => system.listSystemEvents( defined({ limit: n(args, "limit") ?? 50, level: s(args, "level") as "INFO" | "WARN" | "ERROR" | undefined, source: s(args, "source") as system.SystemEventSource | undefined, }), ), }, { name: "admin_list_waitlist", title: "See who asked for access", description: "People who requested access from the marketing site and have not been invited yet. Start here when you're deciding who to let in next: each entry has the address, what they said they're looking for, which site they came from, and when they asked. Entries already turned into invites are included with an invitedAt date, so you can see the whole history — pass pendingOnly true for just the queue. Reading this does not tell anyone anything; use admin_invite_waitlist_signup to actually let someone in.", inputSchema: object({ pendingOnly: bool("Only the people still waiting. Defaults to false, which returns everyone who ever asked."), }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args) => { const [entries, stats] = await Promise.all([ waitlist.listWaitlist({ pendingOnly: b(args, "pendingOnly") ?? false }), waitlist.waitlistStats(), ]); return { ...stats, entries }; }, }, { name: "admin_invite_waitlist_signup", title: "Invite someone off the waitlist", description: "Turn a waitlist request into a real invitation: creates the invite, emails it through Resend, and marks the request as invited so it leaves the queue. Takes the signup id from admin_list_waitlist, not an email address. If email is not configured the invite is still created and the reply carries a link you can send by hand. The request stays on the list afterwards, stamped with the date, so the list remains a record of who asked and when.", inputSchema: object( { id: str("The signup id from admin_list_waitlist"), role: { type: "string", enum: ["MEMBER", "ADMIN"], description: "MEMBER by default. Only the super admin may create ADMINs.", }, }, ["id"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, adminOnly: true, handler: async (args, ctx) => { const result = await waitlist.inviteFromWaitlist({ actor: ctx.user, id: required(args, "id"), role: (s(args, "role") as UserRole | undefined) ?? "MEMBER", baseUrl: ctx.baseUrl, }); return { email: result.invite.email, expiresAt: result.invite.expiresAt, emailSent: result.emailSent, emailError: result.emailError, acceptUrl: result.acceptUrl, note: result.emailSent ? "Invitation emailed and the request marked as invited." : "Invitation created but NOT emailed — send them the acceptUrl yourself, or configure Resend with admin_set_email_config.", }; }, }, { name: "admin_remove_waitlist_signup", title: "Remove a waitlist request", description: "Delete a request from the waitlist for good — spam, a duplicate, or someone who asked to be taken off. This does not revoke an invitation that was already sent; use admin_revoke_invite for that. Irreversible, so read admin_list_waitlist first and remove by id.", inputSchema: object({ id: str("The signup id from admin_list_waitlist") }, ["id"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args) => { await waitlist.removeWaitlistSignup(required(args, "id")); return { removed: true }; }, }, { name: "admin_set_user_role", title: "Change someone's role", description: "Promote a member to admin or demote an admin to member. The super admin cannot be changed, and admins can only act on members.", inputSchema: object( { userId: str("User id"), role: { type: "string", enum: ["MEMBER", "ADMIN"], description: "The new role" }, }, ["userId", "role"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.setUserRole(ctx.user, required(args, "userId"), required(args, "role") as UserRole), }, { name: "admin_set_user_active", title: "Activate or suspend someone", description: "Suspending signs the person out everywhere and blocks their login and their MCP connection. Their data is kept.", inputSchema: object( { userId: str("User id"), isActive: bool("true to reactivate, false to suspend") }, ["userId", "isActive"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.setUserActive(ctx.user, required(args, "userId"), b(args, "isActive") ?? true), }, { name: "admin_delete_user", title: "Delete someone", description: "Destroy an account and everything it owns — career history, highlights, resumes, published links, applications, contacts, tasks and the archive itself. PERMANENT, with nothing behind it: this is the single most destructive tool on the instance, and there is no export step built into it. Read admin_user_detail back to the person you are talking to, say what the numbers are, and get a plain yes. Cannot be used on the instance owner. Suspending with admin_set_user_active blocks their login and their MCP connection while keeping everything, and is what somebody usually means by removing a member.", inputSchema: object({ userId: str("User id") }, ["userId"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => users.deleteUser(ctx.user, required(args, "userId")), }, { name: "admin_set_email_config", title: "Configure email", description: "Set the Resend API key and the address invitations are sent from. Only the fields you pass are changed. Follow with admin_send_test_email to prove it works. instanceName and publicUrl are instance-wide rather than email-only — they are what the sign-in page, invitation links and the Stripe webhook URL are built from — and they can also be set on their own with admin_set_variable.", inputSchema: object({ resendApiKey: str("Resend API key, starts with re_"), resendFromEmail: str("From address on a domain verified in Resend, e.g. hello@yourdomain.com"), resendFromName: str("Display name on outgoing mail"), instanceName: str("What this instance is called, used in invitation emails"), publicUrl: str("Public base URL, used to build invite links, e.g. https://you.up.railway.app"), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { await updateSettings( ctx.user, defined({ resendApiKey: s(args, "resendApiKey"), resendFromEmail: s(args, "resendFromEmail"), resendFromName: s(args, "resendFromName"), instanceName: s(args, "instanceName"), publicUrl: s(args, "publicUrl"), }), ); const settings = await getSettings(); return { configured: emailIsConfigured(settings), resendFromEmail: settings.resendFromEmail }; }, }, { name: "admin_send_test_email", title: "Send a test email", description: "Proves the Resend configuration actually delivers, and doubles as the way to look at what this instance's mail actually looks like. Returns the exact error if it does not send. `template` picks which of the five designs to send: `test` (the default, a short confirmation), `invite` (the real invitation email filled with placeholder material), `waitlist` (the notice the owner gets when a stranger asks for access), `digest` (the Monday summary) or `nudge` (the due-today mail). The samples are marked [Sample] in the subject and their links go nowhere, so proofreading an invitation costs nobody a real invitation token.", inputSchema: object({ to: str("Where to send it. Defaults to your own address."), template: str("Which email to send: test, invite, waitlist, digest or nudge. Defaults to test."), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, adminOnly: true, handler: async (args, ctx) => { const settings = await getSettings(); const to = s(args, "to") || ctx.user.email; const template = s(args, "template") || "test"; const result = await sendEmail({ to, ...renderEmailTemplate(template, settings), settings }); return result.ok ? { ok: true, to, template, id: result.id } : { ok: false, to, template, error: result.error }; }, }, { name: "admin_get_microsoft_config", title: "Check the Microsoft 365 app registration", description: "Whether members can connect their Microsoft 365 or Outlook.com mail and calendar, and the exact redirect URI to register on the app registration in Microsoft Entra — the value behind AADSTS50011 when it does not match. The client secret comes back masked. This is separate from Google sign-in and from the Google client: it is never used to sign in, only for members who choose to connect an Outlook mailbox under Settings → Connections.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (_args, ctx) => { const settings = await getSettings(); return { configured: microsoftIsConfigured(settings), clientId: settings.microsoftClientId, clientSecret: maskSecret(settings.microsoftClientSecret), redirectUri: `${ctx.baseUrl}/api/auth/microsoft/callback`, help: "In Microsoft Entra admin center: App registrations → New registration. Supported account types: 'Accounts in any organizational directory and personal Microsoft accounts', so both work and personal mailboxes can connect. Platform: Web, with redirectUri as the redirect URI. Under API permissions add Microsoft Graph delegated permissions Mail.Read, Calendars.Read, User.Read and offline_access. Under Certificates & secrets create a client secret and paste its VALUE here with admin_set_microsoft_config — the value is shown once.", }; }, }, { name: "admin_set_microsoft_config", title: "Configure the Microsoft 365 app registration", description: "Set or clear the Microsoft Entra app registration members use to connect Outlook mail and calendar. Pass the Application (client) ID and a client secret VALUE. Clearing the client id takes the Microsoft option off the account picker; accounts already connected keep working until their token needs refreshing, then show as needing reconnecting. Recorded in the admin audit log; the secret is recorded as having changed, never as its value.", inputSchema: object({ clientId: str("The Application (client) ID. Empty string to turn the option off."), clientSecret: str("A client secret value from Certificates & secrets. Omit to keep the current one."), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { await updateSettings( ctx.user, defined({ microsoftClientId: s(args, "clientId")?.trim(), microsoftClientSecret: s(args, "clientSecret"), }), ); const settings = await getSettings(); return { configured: microsoftIsConfigured(settings), clientId: settings.microsoftClientId }; }, }, { name: "admin_get_assistant_config", title: "Check the built-in assistant", description: "Whether the chat built into this app is turned on, which model answers, the daily message cap, and what it has cost in tokens over the last thirty days: messages people sent, and input, output, cache-read and cache-write tokens. The API key comes back masked. Empty key means the whole feature is off and the button is never rendered — which is the right setting for an instance whose people connect Claude or another MCP client instead, because that costs this instance nothing. The usage figures are counts and sums across the instance; there is deliberately no way for an admin to read anybody's conversation.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => { const settings = await getSettings(); const since = new Date(Date.now() - 30 * 86_400_000); const usage = await instanceAssistantUsage(since); return { configured: assistantIsConfigured(settings), anthropicApiKey: maskSecret(settings.assistantApiKey), model: settings.assistantModel, dailyMessages: settings.assistantDailyMessages, last30Days: usage, help: "Billing is the instance owner's: the key is theirs and every message is charged to it. The tool definitions and the transcript are cached, so most input tokens on a busy day are cache reads. Turn it off by clearing the key with admin_set_assistant_config.", }; }, }, { name: "admin_set_assistant_config", title: "Configure the built-in assistant", description: "Turn the chat built into this app on or off and set what it costs. Only the fields you pass are changed. Pass an empty anthropicApiKey to turn it off completely — the button stops being rendered and nothing here calls out. dailyMessages is per person, counted in their own time zone, between 1 and 10,000; the default is 50. The assistant runs as whoever is signed in and is served the same tools their own connection would be.", inputSchema: object({ anthropicApiKey: str("Anthropic API key from console.anthropic.com, starts with sk-ant-. Empty turns the feature off."), model: str(`Which model answers. Default ${DEFAULT_ASSISTANT_MODEL}.`), dailyMessages: num("Messages one person may send in a day, 1 to 10,000."), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { const daily = n(args, "dailyMessages"); await updateSettings( ctx.user, defined({ assistantApiKey: s(args, "anthropicApiKey"), assistantModel: s(args, "model"), assistantDailyMessages: daily === undefined ? undefined : Math.max(1, Math.round(daily)), }), ); const settings = await getSettings(); return { configured: assistantIsConfigured(settings), model: settings.assistantModel, dailyMessages: settings.assistantDailyMessages, }; }, }, { name: "admin_get_google_config", title: "Check Google sign-in", description: "Whether people can sign in with Google, who is allowed to, and the exact redirect URI to register in the Google Cloud console — the value that causes redirect_uri_mismatch when it does not match exactly. The client secret comes back masked. `allowSignup` false means Google only signs in people who already have an account or an outstanding invitation, which is the default and keeps the instance invite-only.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (_args, ctx) => { const settings = await getSettings(); return { configured: googleIsConfigured(settings), clientId: settings.googleClientId, clientSecret: maskSecret(settings.googleClientSecret), allowSignup: settings.googleAllowSignup, allowedDomains: settings.googleAllowedDomains, redirectUri: `${ctx.baseUrl}/api/auth/google/callback`, help: "In the Google Cloud console: APIs & Services → Credentials → Create credentials → OAuth client ID → Web application. Add redirectUri under Authorised redirect URIs exactly as given, then paste the client ID and secret here with admin_set_google_config. An existing member or an invited person can sign in with Google as soon as it is configured; allowSignup only governs people nobody invited.", }; }, }, { name: "admin_set_google_config", title: "Configure Google sign-in", description: "Set the Google OAuth client id and secret, and decide who may sign up. Only the fields you pass are changed. Setting a client id and secret puts a Continue with Google button on the sign-in page; clearing the client id takes it away and changes nothing else, so it is a safe thing to undo. `allowSignup` is the one with consequences: true lets ANYONE with a Google account create an account on this instance, so pair it with allowedDomains unless you really mean the whole internet. False — the default — still lets existing members and anyone holding an unexpired invitation sign in with Google; it only turns away strangers. Follow with admin_get_google_config for the redirect URI to register.", inputSchema: object({ clientId: str("OAuth client ID, ends in .apps.googleusercontent.com"), clientSecret: str("OAuth client secret, usually starts GOCSPX-"), allowSignup: bool("True lets a Google account nobody invited create an account here"), allowedDomains: str("Comma-separated email domains new sign-ups must be on, e.g. 'acme.com'. Empty means any domain. Only consulted when allowSignup is true."), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { await updateSettings( ctx.user, defined({ googleClientId: s(args, "clientId"), googleClientSecret: s(args, "clientSecret"), googleAllowSignup: b(args, "allowSignup"), googleAllowedDomains: s(args, "allowedDomains"), }), ); const settings = await getSettings(); return { configured: googleIsConfigured(settings), allowSignup: settings.googleAllowSignup, allowedDomains: settings.googleAllowedDomains, redirectUri: `${ctx.baseUrl}/api/auth/google/callback`, }; }, }, { name: "admin_get_billing_config", title: "Check billing configuration", description: "Whether Stripe billing is wired up for hosting other people on this instance for a fee, how many users currently pay, and the exact webhook URL to paste into the Stripe Dashboard. Keys come back masked. Billing only governs users who arrived through a Stripe checkout — the owner and free invitees are never touched by it.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (_args, ctx) => { const settings = await getSettings(); return { configured: billingIsConfigured(settings), stripeSecretKey: maskSecret(settings.stripeSecretKey), stripeWebhookSecret: maskSecret(settings.stripeWebhookSecret), paymentLink: settings.stripePaymentLink, billedUsers: await billedUserCount(), webhookUrl: `${ctx.baseUrl}/api/stripe/webhook`, help: "In Stripe: create a Product with a recurring Price and a Payment Link for it, then a webhook pointed at webhookUrl with the events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed. Someone who pays through the link is invited automatically; a lapsed subscription suspends them, and paying again reactivates the same workspace.", }; }, }, { name: "admin_set_billing_config", title: "Configure billing", description: "Set the Stripe API key, the webhook signing secret, and the public Payment Link for this instance. Only the fields you pass are changed. Prefer a RESTRICTED key (rk_...) with read-only Customers and Subscriptions over the full secret key — reading those two things is all this app ever does with Stripe, and a restricted key that leaks cannot move money or alter the Stripe account. Follow with admin_get_billing_config to see the webhook URL to register in Stripe.", inputSchema: object({ stripeSecretKey: str("Stripe API key — a restricted rk_ key with read-only Customers and Subscriptions is enough and safer than the full sk_ key"), stripeWebhookSecret: str("Webhook signing secret, starts with whsec_"), stripePaymentLink: str("The Stripe Payment Link people pay through, e.g. https://buy.stripe.com/..."), }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { await updateSettings( ctx.user, defined({ stripeSecretKey: s(args, "stripeSecretKey"), stripeWebhookSecret: s(args, "stripeWebhookSecret"), stripePaymentLink: s(args, "stripePaymentLink"), }), ); const settings = await getSettings(); return { configured: billingIsConfigured(settings), paymentLink: settings.stripePaymentLink }; }, }, { name: "admin_sync_billing", title: "Resync billing from Stripe", description: "Asks Stripe for the current subscription state and reconciles this instance against it — the recovery path for a missed webhook. Pass an email to sync one billed user, or nothing to sync everyone with a Stripe customer attached. Reports what changed per person: activated, suspended, or unchanged. Safe to run any time.", inputSchema: object({ email: str("One billed user's email. Omit to sync all billed users.") }), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true, }, adminOnly: true, handler: async (args, ctx) => { const results = await syncAllBilling(ctx.baseUrl, s(args, "email") || undefined); return { synced: results.length, results }; }, }, { name: "admin_link_billing", title: "Link or unlink a member and their Stripe customer", description: "Attaches an EXISTING member to their Stripe customer so billing starts governing their access. This never happens automatically: a checkout email is whatever the payer typed, so the unattended webhook only ever invites strangers — connecting a current member to a subscription is a deliberate admin act, and this tool is that act. Pass their email; their Stripe customer is found by the same email in Stripe's records, or pass customerId when Stripe holds several. Pass unlink true to detach someone from billing entirely — the recovery hatch if a link was wrong; it also ends billing's authority over their account. The owner can never be linked.", inputSchema: object( { email: str("The member's email on this instance"), customerId: str("A specific Stripe customer id (cus_...), when email alone is ambiguous"), unlink: bool("True to detach this member from billing instead of linking"), }, ["email"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true, }, adminOnly: true, handler: async (args, ctx) => linkBillingCustomer({ email: required(args, "email"), customerId: s(args, "customerId") || undefined, unlink: b(args, "unlink") ?? false, baseUrl: ctx.baseUrl, }), }, { name: "admin_list_variables", title: "List instance variables", description: "Every configurable value on this instance in one list: its key, what it does, what it is set to now, and whether it is still on the built-in default. This is the whole of what a self-hosted instance stores as configuration, so start here when someone asks where a setting lives or why the app is behaving a certain way. Secrets come back masked — no tool ever returns a raw key. Variables an admin added by hand are marked known:false; they have no form in the app and are read by whatever feature asked for them.", inputSchema: object({}), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async () => ({ variables: await listVariables() }), }, { name: "admin_set_variable", title: "Set an instance variable", description: "Changes one instance setting by key — the general way in, for anything without a tool of its own. Take the key from admin_list_variables and send the new value as a string; an on-off variable takes \"1\" or \"0\". Prefer admin_set_email_config or admin_set_billing_config where they apply, because they also report whether that area now works. Sending an empty value for a secret leaves it alone rather than clearing it — admin_delete_variable is how you clear one. A key nothing recognises creates a new variable, which is how a setting exists before it has a screen: lowercase letters, numbers and underscores. Every change is written to the audit log against your name, values included, so never put a secret in a key that is not declared as one.", inputSchema: object( { key: str("The variable's key, e.g. instance_name — from admin_list_variables"), value: str('The new value as a string. "1" or "0" for an on-off variable.'), }, ["key", "value"], ), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => { const key = required(args, "key"); const value = s(args, "value"); if (value === undefined) throw new Error('Missing required string argument "value"'); const changed = await setVariables(ctx.user, { [key]: value }); const after = (await listVariables()).find((variable) => variable.key === key); return { key, changed: changed.length > 0, value: after?.value ?? value, variable: after }; }, }, { name: "admin_delete_variable", title: "Clear an instance variable", description: "Removes a variable's stored value. A setting the app declares falls back to its built-in default — clearing the Resend key stops every invitation email, clearing company_logos turns logos back on — and a variable an admin added disappears entirely. Call admin_list_variables first to see what the default would be, because this is the one settings call with no undo. Recorded in the audit log.", inputSchema: object({ key: str("The variable's key, from admin_list_variables") }, ["key"]), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, adminOnly: true, handler: async (args, ctx) => deleteVariable(ctx.user, required(args, "key")), }, ]; /** Members never even see the admin tools in tools/list. */ // --------------------------------------------------------------------------- // Prompts — these surface in Claude as ready-made workflows // --------------------------------------------------------------------------- export type McpPrompt = { name: string; title: string; description: string; adminOnly?: boolean; arguments: { name: string; description: string; required?: boolean }[]; build: (args: Record) => string; }; export const prompts: McpPrompt[] = [ { name: "tailor_resume", title: "Tailor a resume to a job", description: "Check a job against the evidence in Me, then — if it is worth it — produce a tailored resume attached to that job. The first half is a read-only gap report (backed, thin, missing) and is the whole answer when they only asked whether to apply.", arguments: [ { name: "application_id", description: "The job on the board, if it is there" }, { name: "job_description", description: "The posting, pasted, if it is not on the board" }, { name: "company", description: "Company name, for a pasted posting" }, ], build: (args) => `Tailor a resume for this job. ${args.job_description ?? (args.application_id ? "On the board — read it with get_application." : "No posting given — ask me for it, or for the job on the board.")} The rule that governs everything here: nothing goes on the resume that the evidence in Me cannot back. The quiet upgrade — "helped with" becoming "led", a credit becoming a hire — is how resumes actually go wrong. READ FIRST — nothing is written in this half. 1. If the job is on the board, get_application for the posting, then posting_keywords: term by term, what the posting asks for and where it lands (evidenced, covered by a recorded transferable, or missing). 2. get_me_snapshot. Read keywordPolicy.permits (how close to the posting's words you may get), writingRules (bind every line, never quoted), neverOnADocument (never on the page) and notSettled (never used — ask). 3. Pull out the 8-12 requirements the posting actually rewards, in priority order. 4. For each, search_me — the posting's own words first, then the words I would have used for the work. Only a hit whose use is "evidence" counts. 5. Sort every requirement into BACKED (quote the strongest evidence and its role), THIN (say what exists and what the gap is) or MISSING. Never move one to BACKED to be encouraging. If I only asked whether to apply or how well I fit, stop here: give me the three lists and your read on whether tailoring is worth it, and ask one concrete question per THIN or MISSING item — anything I answer goes into Me with append_role_background. THEN WRITE. 6. If the job is not on the board, offer to put it there (capture_job_posting with the link, or create_application) — do not do it unasked. Then tailor_resume_for_application: it copies my base resume, names and targets it, and attaches it to the job. 7. Read its \`unbacked\` list: lines copied from the base that nothing in Me stands behind. A claim corrected in Me since the base was written is one of these. Rewrite each from Me or drop it — never carry one forward unread. 8. get_resume_format if you have not already, then update_resume with the whole document. Every experience entry keeps the roleId of its job in Me. Bullets lead with a strong verb, name the scope and end in an outcome from that role's EVIDENCE; spell an evidenced term the way the posting spells it, and place a covered term only as keywordPolicy allows. 9. export_resume_pdf for the real page count. If it runs long, check_resume_fit, and propose the cuts to me rather than making them. 10. trace_resume_evidence last. Any bullet still unbacked is rewritten from Me, dropped, or put to me as a question. Finish by telling me what you emphasised, what you cut, the requirements nothing backs (the MISSING list — never paper over it), and any keyword you placed as comparable rather than as experience, so I can answer for it in a room.`, }, { name: "write_letter", title: "Write a letter", description: "Draft anything they write that is not a resume — a cover letter, a cold message, a referral ask, a thank-you, a reply, or one of the four that are about them rather than to anybody: a LinkedIn About, a headline, a self-review, a brag doc. Gathers the posting, the evidence and their own earlier documents of the same kind first, so it sounds like them and every claim in it is true.", arguments: [ { name: "kind", description: "COVER_LETTER, OUTREACH, REFERRAL_ASK, THANK_YOU, REPLY, LINKEDIN_ABOUT, HEADLINE, SELF_REVIEW or BRAG_DOC. Defaults to a cover letter" }, { name: "application_id", description: "The job it is about" }, { name: "contact_id", description: "The person it is going to" }, { name: "notes", description: "Anything they want said, or the message being replied to" }, ], build: (args) => `Write me a ${args.kind ?? "COVER_LETTER"}. ${args.notes ?? "No extra notes."} Work in this order: 1. Call prep_letter with the kind and whichever ids I gave you. Read intent — it says what this kind of letter is actually for — and read missing before you write a word. 2. Read priorLetters properly. Those are letters I wrote myself, and they are the only reliable description of how I sound. Match the length, the register and the rhythm. If there are none, ask me how I want it to read rather than defaulting to cover-letter voice. 3. Build the argument out of evidence only. Every specific claim — a number, a system, a team size, an outcome — has to trace back to something that came back from Me. Never invent an employer, a date, a metric or a project. Where the posting asks for something I cannot evidence, leave it out and tell me afterwards. writingRules bind every line without being quoted; nothing in neverOnADocument goes on the page, and nothing in notSettled is used — ask me instead. 4. Use application.companyNotes and application.recentActivity for the part that is about THEM. A letter that could have been sent to any employer is a letter nobody answers. If this is a LINKEDIN_ABOUT, HEADLINE, SELF_REVIEW or BRAG_DOC there is nobody to write to — skip this step entirely and build it out of evidence and profile. Step 3 still holds: nothing goes in that I did not do. 5. Draft it. Short. No "I am writing to express my interest", no restating the resume, no adjectives doing work a fact should do. 6. Call create_letter to save it, with the kind, a title of " — ", the recipient if I named one, and application_id and contact_id where I gave them. Leave sent_at unset — it is a draft until I say otherwise. 7. Tell me three things: what I am claiming and where each claim comes from, what the posting asked for that I could not evidence, and anything from missing that would make the next one better.`, }, { name: "mine_role_background", title: "Mine a role's background into highlights", description: "Distil a role's evidence into polished, reusable achievement bullets, under that role's own rules, and save them once they say yes.", arguments: [{ name: "role_id", description: "The role id to mine (omit to be asked)" }], build: (args) => `Turn a role's raw background into reusable resume bullets. ${args.role_id ? `Use role id ${args.role_id}.` : "Call list_roles first and ask me which role to mine."} 1. Call get_role. Read \`rules\`, \`caveats\` and \`open\` BEFORE anything else: the rules bind every bullet you write (a figure or a framing a rule forbids never goes in, whatever the background says), caveats are my own positioning notes and never become a bullet, and an open item is a fact I have not settled — never mine one; ask me about it at the end. 2. Mine ONLY from \`resumeEvidence\`, never from the raw background. A highlight is printed on resumes and counted as backing, so a caveat that becomes one is a caveat on paper. 3. Call list_highlights for that role so you do not duplicate what already exists. 4. Extract every distinct accomplishment. For each, write one bullet: strong verb, specific scope, quantified outcome. Keep the real numbers from the evidence, exactly as written — never round one up, and never attach a company-wide figure to me unless the evidence says it was mine. 5. Rate each 1-5 on strength and tag it for retrieval. 6. Show me the drafts and get a yes before anything is saved. Then save them in one create_highlights call. 7. Flag anything where the evidence hints at impact but gives no number, so I can fill it in, and list the open items you skipped.`, }, { name: "pipeline_review", title: "Weekly pipeline review", description: "Review the job search: what is stalled, who needs chasing, what to do next.", arguments: [], build: () => `Run my weekly job search review. 1. Call diagnose_search FIRST. It tells you which step of the funnel is losing people, and the whole review should be built around that answer rather than around the counts. 2. Call morning_brief with since_days 7, ahead_days 7 and first 15. It carries what is due, what moved this week, what is coming, what has gone quiet by the app's own rule, the pipeline's shape and an ordered list of what to do first — one read, already computed. 3. Call list_tasks with done: false, for the open tasks that have no date and so are not in the brief. 4. Call list_referrals with thanks_owed true: a converted referral nobody thanked is the most expensive loop in a search. Then give me: - A two-line summary of where the search stands. - What has gone quiet and what is overdue, from the brief — its quiet list is the app's own rule for each stage, so do not apply a stricter one of your own. - A prioritised list of what to do this week, most important first, each tied to a specific company. - Draft the follow-up messages for anything overdue. Read the application's timeline with get_application so each one refers to what was actually said — a follow-up that mentions the thing the recruiter told me gets answered. - Create tasks for the actions I should take, with due dates. Lead with what diagnose_search found. If it says the problem is the resume or the targeting, do not give me a list of follow-ups as though volume were the answer — tell me the thing that is broken and what to do about it this week. Be direct about the bad news; I would rather hear it than have it phrased kindly. If it says there is not enough data yet, say that and keep the review short.`, }, { name: "research_company", title: "Research a company into the CRM", description: "Gather what is known about a company, work out what is missing, and write it back to their record without losing what was already there.", arguments: [ { name: "company", description: "Company name", required: true }, { name: "focus", description: "Anything specific to dig into, e.g. 'the interview loop'" }, ], build: (args) => `Research ${args.company ?? "this company"} and put what you find on their record. 1. Call list_companies with search: "${args.company ?? ""}" to find their id, then get_company for everything already on file — including every application and contact I have there. 2. Tell me what is already known and what is missing.${ args.focus ? ` 3. Focus especially on: ${args.focus}` : "" } Then write it back with update_company. This is the part to get right: - update_company REPLACES the notes field. Take what get_company returned, combine it with what is new, and write the whole thing back. Do not send only the new part. - Set website to their OWN domain if it is missing — not a Greenhouse, Lever or Ashby link, which is the job board rather than the employer. It is what puts their logo on my pipeline. - Fill industry, size and location if you can. Worth recording, because it is what I will want the night before an interview: what they actually do and how they make money, the interview loop if it is known, who I know there, and the honest version of why I do or do not want this. Do not invent facts about the company. If you are working from what I have told you, say so; if you are unsure, mark it as unconfirmed in the notes rather than stating it flatly.`, }, { name: "inbox_review", title: "Inbox review: what moved in their mail and calendar", description: "Go through the person's own mail and calendar for every open application and every contact with a ping due, find what has happened that the pipeline does not know yet — a reply, a scheduled interview, a rejection, an offer — and queue the logging and stage changes that would bring the pipeline up to date onto their dashboard, each with the line of evidence behind it. Nothing is written until they accept it. Reads mail through your own mail and calendar tools when you have them (a Gmail connector, say), and through an account connected to this app when you do not.", arguments: [ { name: "days", description: "How far back to look. Default 7." }, ], build: (args) => `Bring my pipeline up to date from my inbox and calendar, looking back ${args.days ?? "7"} days. Work in this order: 1. Call list_applications (open ones) and list_follow_ups. Those are the companies and people to look for. 2. Find a way into my mail. If you have mail and calendar tools of your own — a Gmail or Google Calendar connector, say — use them: search for each company's name and each contact's address over the last ${args.days ?? "7"} days, and READ every thread that matters rather than trusting a snippet. If you do not, call list_linked_accounts; with an account connected here, run_mail_sweep with days=${args.days ?? "7"} makes one request per mailbox for the whole pipeline, queues what a rule can prove, and hands back a needsReading list — call get_email_thread on each of those and read it. With neither, stop and say so; do not guess at my mail. 3. Look at the calendar for the same window and ${args.days ?? "7"} days forward, with your own calendar tool or search_calendar, and note interviews or calls that are on the calendar but not on the pipeline. 4. Tell me, application by application, what moved and quote the line that says so. Be specific about dates and numbers; never round a salary or a deadline. 5. Then call propose_changes with one proposal per thing the READING turned up — if run_mail_sweep ran, it has already queued what it could prove, so do not queue those again — LOG_ACTIVITY (type INTERVIEW, EMAIL_RECEIVED, REJECTION, OFFER as fits, with the date it happened), MOVE_STAGE, SET_FOLLOW_UP, CREATE_TASK or CREATE_CONTACT — quoting the line from the thread as the evidence on each. That queues them on my dashboard, where I can accept or dismiss them one at a time whenever I get to it. Call list_proposals first so you do not queue the same suggestion twice, and check the refused list in the result. 6. Do NOT call accept_proposal. Nothing is written until I say so. Then tell me what you queued, and read back anything that was refused. Skip newsletters, job-board digests and anything automated that does not concern a specific application. If a thread involves a person who is not a contact yet, suggest create_contact with their name and address.`, }, { name: "onboard_teammate", title: "Invite and onboard someone", description: "Invite a person to this instance and walk them through their first steps.", adminOnly: true, arguments: [ { name: "email", description: "Who to invite", required: true }, { name: "role", description: "MEMBER or ADMIN" }, ], build: (args) => `Invite ${args.email ?? "someone"} to this Hired instance. 1. Call admin_list_variables and check resend_api_key and resend_from_email. If email is not configured, tell me plainly and carry on — the invite still works, I will just send the link myself. 2. Call admin_invite_user with email "${args.email ?? ""}"${args.role ? ` and role ${args.role}` : ""}. 3. If emailSent is false, give me the acceptUrl in full and tell me to send it to them. 4. Then write me a short message I can paste to them explaining what this is: a place to dump everything about their career, build tailored resumes from it, and track applications — and that they connect their own Claude to it from the Settings page once they are in.`, }, { name: "log_my_week", title: "Log what happened this week", description: "Turn a rambling update into structured background entries and pipeline updates.", arguments: [{ name: "update", description: "What happened — write it however you like", required: true }], build: (args) => `Here is what happened this week. File it properly. ${args.update ?? ""} 1. Anything about my current job or a past job → append_role_background on the right role (call list_roles first to find ids). Keep my numbers and specifics, in my words. Do not polish it into a resume bullet here. 2. Anything about a company I am talking to → log_activity on the application, and move_application_stage if it moved. 3. Anything I said I would do → create_task with a due date. 4. Anything that does not fit a role or a company → create_note. Then confirm what you filed and where, and ask me about anything that was ambiguous. If a role got something worth printing, offer mine_role_background for it — turning raw material into resume bullets is a separate, deliberate step that reads that role's rules first.`, }, ]; export const promptsByName = new Map(prompts.map((prompt) => [prompt.name, prompt])); /** * Same rule as tools: members never see the admin workflows, and a narrowed * connection only sees the workflows whose every step it can actually take. * * Defined further down, once the scope tables exist — this shim keeps the * export where callers expect it. */ export function promptsFor(user: { role: UserRole }, scope: McpScope = "FULL"): McpPrompt[] { return promptsForScope(user, scope); } // --------------------------------------------------------------------------- // The workflows, again, as tools // --------------------------------------------------------------------------- /** * MCP prompts are a client-optional surface. Tools are not. * * The workflows above are the most considered design in this file — * tailor_resume alone encodes the whole seven-step loop including "do not * invent anything" — and in a client that doesn't render prompts they are * simply unreachable. That fails this project's own rule: a feature isn't done * until it's callable from a conversation. * * So every prompt is also published as a tool. Calling one returns the same * instruction text `prompts/get` would have returned, which the model then * follows. This is a wrapper, not a copy — the text lives in exactly one place, * so the two surfaces cannot drift. */ function promptAsTool(prompt: McpPrompt): McpTool { const properties: Json = {}; for (const argument of prompt.arguments) { properties[argument.name] = str(argument.description); } const requiredArgs = prompt.arguments.filter((a) => a.required).map((a) => a.name); return { name: prompt.name, title: prompt.title, description: `${prompt.description} Returns a step-by-step plan for this job — follow the steps it gives you, ` + `calling the tools it names. This is a workflow, not a data lookup: nothing is read or written until you act on it.`, inputSchema: object(properties, requiredArgs), // A workflow tool hands back the plan it was always going to hand back. // It reads nothing and writes nothing; the tools it names do that. annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, adminOnly: prompt.adminOnly, handler: async (args) => { const stringArgs: Record = {}; for (const argument of prompt.arguments) { const value = s(args, argument.name); if (value !== undefined) stringArgs[argument.name] = value; else if (argument.required) throw new Error(`Missing required string argument "${argument.name}"`); } return prompt.build(stringArgs); }, }; } /** * Client hints, sent as each tool's `_meta`. * * These are Anthropic's keys, read by Claude and Claude Code and ignored by * every other client, which is why they live in three small tables here rather * than as fields on a hundred and fifty tool literals: the policy is worth * reading in one place, and a tool that is in none of them needs no entry. * * Why they matter at all: a client with tool search on does not load this * server's tools until it searches for them, and it caps how much of a result * it will read. Both defaults are right for a small server and wrong for this * one. */ /** * Kept loaded even when a client defers the rest behind tool search. * * The bar is deliberately high — a large always-load set is the same thing as * having no tool search — so it is exactly two kinds of tool: the one the * server briefing names as the way in, and the reads whose absence causes a * write to destroy something. get_resume_format, list_tags and get_company are * the "read before you write" guards; without them loaded, an assistant writes * a resume in the wrong shape, mints a near-duplicate label, or replaces a * company's research with one fact. */ const ALWAYS_LOAD = new Set([ "search_me", "list_applications", "list_schedule", "get_resume_format", "list_tags", "get_company", ]); /** * Results that are legitimately large, and what each may spend. * * A client's default cap is sized for a tool that returns a row. These return a * career, a mailbox or a spreadsheet, and truncating one silently is worse than * a long result: the assistant then reasons from half a work history without * knowing the other half existed. The ceiling the key accepts is 500,000. */ const MAX_RESULT_CHARS: Record = { get_me_snapshot: 200_000, export_csv: 200_000, list_correspondence: 120_000, trace_resume_evidence: 100_000, compare_resumes: 80_000, list_archive: 80_000, get_resume: 80_000, // Forty terms each carrying up to three excerpts, and a brief that carries a // week of the schedule plus the pipeline's stats. skills_gap: 60_000, morning_brief: 60_000, admin_audit_log: 80_000, }; /** * Never run these without a person in the loop. * * The test is not "destructive" — most of this server is, and the archive makes * almost all of it reversible. It is "cannot be undone by any tool here, and * takes career material with it": the archive's own destroyers, a company * merge, an account, and every delete of something a person wrote about their * own working life and has no copy of anywhere else. In the built-in drawer * this set is the only thing between a request — or text a stranger planted in * a posting — and those deletes, and every MCP client reads the same flag. * * Housekeeping deletes stay off it on purpose: a task, a tag, a saved view or * a checklist line is one click to make again. */ const REQUIRES_USER_INTERACTION = new Set([ "delete_archived", "empty_archive", "merge_companies", "admin_delete_user", "delete_role", "delete_highlight", "delete_note", "delete_extra", "delete_resume", "delete_letter", "delete_offer", "delete_attachment", "delete_interview", "delete_interview_question", "delete_referral", ]); /** The `_meta` for one tool, or undefined when it needs none. */ export function metaFor(name: string): Json | undefined { const meta: Json = {}; if (ALWAYS_LOAD.has(name)) meta["anthropic/alwaysLoad"] = true; const cap = MAX_RESULT_CHARS[name]; if (cap !== undefined) meta["anthropic/maxResultSizeChars"] = cap; // Must be a JSON true rather than a truthy string; the client checks the type. if (REQUIRES_USER_INTERACTION.has(name)) meta["anthropic/requiresUserInteraction"] = true; return Object.keys(meta).length > 0 ? meta : undefined; } /** The data tools plus the workflow tools. Order matters only for display. */ export const allTools: McpTool[] = [...tools, ...prompts.map(promptAsTool)]; export const toolsByName = new Map(allTools.map((tool) => [tool.name, tool])); // A name on the confirm-first list that no tool carries is a gate that guards // nothing — usually a tool renamed or merged away. Fail loudly at load. for (const name of REQUIRES_USER_INTERACTION) { if (!toolsByName.has(name)) throw new Error(`REQUIRES_USER_INTERACTION names ${name}, which is not a tool.`); } // --------------------------------------------------------------------------- // Scopes: what one connection is SERVED // --------------------------------------------------------------------------- /** * Which tool names belong to each scope, worked out once at module load. * * Per-request would mean walking two hundred tools on every `tools/list`, and * the table is static, so it is built with the module: eight entries, one per * scope per role. * * FULL is not computed from sections at all. It is defined as "exactly what * this server served before scopes existed", which is the promise the migration * makes, and the only way to keep that promise is to take the same code path. */ function namesInSections(keys: readonly string[]): Set { const names = new Set(); for (const key of keys) { const section = SECTIONS.find((candidate) => candidate.key === key); if (!section) throw new Error(`scopes.ts names a section "${key}" that SECTIONS does not have`); const from = tools.findIndex((tool) => tool.name === section.first); const to = tools.findIndex((tool) => tool.name === section.last); if (from < 0 || to < 0 || to < from) { throw new Error( `Section "${key}" runs ${section.first}..${section.last}, which is no longer a range in tools.ts`, ); } for (const tool of tools.slice(from, to + 1)) names.add(tool.name); } return names; } /** * A workflow's membership is DERIVED, not declared: a prompt is served in a * scope only when every tool its body names is served there. * * Declaring it would be a second list to keep right, and the failure mode is * silent — a workflow that tells a client to call a tool it cannot see reads * as the app being broken. Deriving it means adding a step to `tailor_resume` * that needs a pipeline write simply moves it to FULL, with no edit anywhere. */ function workflowFits(prompt: McpPrompt, served: Set): boolean { // Build with empty arguments purely to read the body; nothing is executed. let body: string; try { body = prompt.build({}); } catch { // A workflow whose build refuses empty arguments cannot be inspected, so // it is FULL-only rather than silently included. return false; } const named = [...body.matchAll(/\b([a-z][a-z0-9_]{3,})\b/g)] .map((match) => match[1]) .filter((word) => toolsByName.has(word)); return named.every((name) => served.has(name)); } type ScopeTable = { tools: McpTool[]; prompts: McpPrompt[]; names: Set }; function buildScope(scopeKey: McpScope, admin: boolean): ScopeTable { const roleTools = admin ? allTools : allTools.filter((tool) => !tool.adminOnly); const rolePrompts = admin ? prompts : prompts.filter((prompt) => !prompt.adminOnly); if (scopeKey === "FULL") { return { tools: roleTools, prompts: rolePrompts, names: new Set(roleTools.map((t) => t.name)) }; } const scope = scopeByKey.get(scopeKey); if (!scope) throw new Error(`Unknown scope ${scopeKey}`); const inSections = namesInSections(scope.sections); const allowed = new Set([...inSections, ...scope.extras, ...CORE_TOOLS]); // allTools carries every workflow TWICE — once here as a tool, and once in // `prompts`, because prompts are a client-optional surface and tools are not. // A name filter cannot see that: no workflow is in a section or an extra, so // the tool copy fell out of every narrowed scope and a WRITING connection got // tailor_resume in prompts/list and nowhere else — invisible to exactly the // clients the tool copy exists for. So they are set aside here and decided as // workflows below, by the same workflowFits that decides the prompt. const workflowNames = new Set(prompts.map((prompt) => prompt.name)); const served = roleTools.filter((tool) => { if (workflowNames.has(tool.name)) return false; // Admin tools are excluded from EVERY non-FULL scope, including for an // admin and including READONLY. An admin's pipeline client has no business // being offered admin_delete_user, and this is one line rather than a // fifth scope. if (tool.adminOnly) return false; if (!allowed.has(tool.name)) return false; if (scope.readOnly && tool.annotations.readOnlyHint !== true) return false; return true; }); const names = new Set(served.map((tool) => tool.name)); // A fixed point rather than one pass, because a workflow may name another — // log_my_week ends by offering mine_role_background. Judged against // the tools alone, a workflow like that could never fit a narrowed scope even // when everything it needs is served. So: start from the tools, add the // workflows that fit, and go round again until nothing new fits. It // terminates because the set only grows and is bounded by the array. const reachable = new Set(names); const fits: McpPrompt[] = []; const candidates = rolePrompts.filter((prompt) => !prompt.adminOnly); for (;;) { const next = candidates.filter( (prompt) => !reachable.has(prompt.name) && workflowFits(prompt, reachable), ); if (next.length === 0) break; for (const prompt of next) { reachable.add(prompt.name); fits.push(prompt); } } // Back into the array's own order, so two scopes never disagree about it. const fitNames = new Set(fits.map((prompt) => prompt.name)); fits.sort((a, b) => candidates.indexOf(a) - candidates.indexOf(b)); return { // Appended rather than interleaved, which is the order FULL serves too. tools: [...served, ...roleTools.filter((tool) => fitNames.has(tool.name))], prompts: fits, names: new Set([...names, ...fitNames]), }; } const SCOPE_TABLES = new Map(); for (const scope of SCOPE_VALUES) { for (const admin of [false, true]) { SCOPE_TABLES.set(`${scope}:${admin}`, buildScope(scope, admin)); } } const tableFor = (user: { role: UserRole }, scope: McpScope): ScopeTable => SCOPE_TABLES.get(`${scope}:${isAdmin(user)}`) ?? SCOPE_TABLES.get(`FULL:${isAdmin(user)}`)!; export function toolsFor(user: { role: UserRole }, scope: McpScope = "FULL"): McpTool[] { return tableFor(user, scope).tools; } function promptsForScope(user: { role: UserRole }, scope: McpScope): McpPrompt[] { return tableFor(user, scope).prompts; } /** * Why a tool is not served here, or null when it is. * * The sentence matters: a client that asked for something narrowed away should * be able to say what happened and how its person widens it, rather than * guessing that the app is broken. */ export function outOfScope( name: string, user: { role: UserRole }, scope: McpScope, ): string | null { const tool = toolsByName.get(name); if (!tool) return `There is no tool called ${name}.`; if (tool.adminOnly && !isAdmin(user)) { return `${name} is an admin tool and this account is not an admin.`; } if (tableFor(user, scope).names.has(name)) return null; return ( `${name} is not served on this connection, which is scoped to "${scopeLabel(scope)}" — ` + `${scopeBlurb(scope)} Nothing is wrong: the account still has that tool. ` + `They can widen this connection under Settings → Connections, or use a connection that is not narrowed.` ); }