agentleFS
CodesWhatCodesWhat/careerrat/.agents/skills/email-comms/SKILL.md

email-comms

Skill4 starsChanged 10 days agoMITView on GitHub

Draft, reply to, follow up on, summarize, and track job-search email and recruiter-message threads — including thank-yous, scheduling, negotiation, and cold outreach — while persisting full thread context in the CareerRat tracker.

The file

Show its rules and text

We split this skill into the rules it gives an agent

45 rules. About 0 are common, 17 are distinctive, and 28 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈17

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 28

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    When the user pastes or types a prompt of the form:

    line 38
  2. Very specific:

    Treat this as an outbound-follow-up (or thank-you if the notification kind is thank-you).

    line 44
  3. Very specific:

    Mode detection: run careerrat data status.

    line 56
  4. Distinctive:

    When the user says they already sent a message, already replied, or already completed the action manually (e.g.

    line 61
  5. Very specific:

    If status is advancing to waiting (the common case): careerrat data comm mark-sent <comm-id> --at <iso> — sets status: waiting, clears …

    line 72
  6. Very specific:

    Legacy workspace (no DB): write to tracker.json in one operation — no partial writes.

    line 73
  7. Very specific:

    Legacy workspace (no DB): run careerrat tracker --verify, confirm clean exit, then snapshot (careerrat tracker).

    line 77
  8. Distinctive:

    If the type is ambiguous, ask one clarifying question before proceeding.

    line 160
  9. Very specific:

    Look the role up on the company's own careers page and pass what you found.

    line 169
  10. Distinctive:

    Always include: "Do not run, install or open any code, repository, archive or file this contact sends until you have confirmed them through …

    line 173
  11. Very specific:

    Read workspace/tracker.json.

    line 184
  12. Distinctive:

    If no match and this is a real communication (not a test note):

    line 190
  13. Distinctive:

    Create a new communications record with at minimum:

    line 192
  14. Very specific:

    Legacy workspace (no DB): write the updated workspace/tracker.json immediately.

    line 207
  15. Distinctive:

    Do not proceed with a draft until the record exists.

    line 209
  16. Distinctive:

    Read each of the following before drafting:

    line 215
  17. Very specific:

    If the thread is for a role but no applicationId is linked, ask the user to confirm the role before drafting.

    line 222
  18. Very specific:

    If writing-style.md is current: proceed.

    line 233
  19. Distinctive:

    Read candidate profile config through the shared DB-first accessor and extract:

    line 239
  20. Very specific:

    If the user states a new comp boundary mid-flow (e.g., "I won't go below $210K on this one") — confirm-first, then run careerrat gate …

    line 253
  21. Distinctive:

    Run this step immediately after STEP 4.

    line 259
  22. Distinctive:

    If the inbound message cites the candidate's location or remote status to justify a lower offer:

    line 263
  23. Very specific:

    Read the most recent workspace/research/comp-bench-<role>-<loc>-<yyyy-mm>.md artifact.

    line 265
  24. Distinctive:

    If no benchmark artifact exists, do NOT cite a market number.

    line 266
  25. Very specific:

    If the artifact is stale (> 6 months), surface a warning to the user before drafting: Benchmark artifact may be stale — run research-comp …

    line 267
  26. Very specific:

    Never cite current_base. Never cite the discounted-locale figure the employer used — rebut it structurally, not numerically.

    line 271
  27. Very specific:

    Read candidate/honesty.yml (claims.do_not_fabricate) before this sub-step.

    line 281
  28. Distinctive:

    Use the competing offer as leverage to invite a stronger response, not as a threat.

    line 288
  29. Very specific:

    Do not fabricate one. Say so plainly to the user before drafting: No competing offer or deadline found — coaching to honest alternatives …

    line 292
  30. Very specific:

    When cash_over_equity is true, do not offer equity concessions before exhausting sign-on and start-date options.

    line 326
  31. Distinctive:

    If the employer sets a deadline, acknowledge it and respond within it, or ask for a short extension (24–48 h) once.

    line 330
  32. Distinctive:

    If no deadline is set, the candidate controls timing.

    line 331
  33. Very specific:

    When to hold: round 1 (opening) and any round where the gap to target_base is > 10% — conceding early signals the floor is lower than it is.

    line 332
  34. Very specific:

    When to close: the offer clears minimum_base / oe_min_base and any one of (a) the employer states this is final, (b) further rounds risk …

    line 333
  35. Very specific:

    Persistence: every round's outbound draft is a negotiation-comp message appended to communications[].messages[] via STEP 6.

    line 335
  36. Distinctive:

    Write a complete, finished artifact: subject line + body.

    line 343
  37. Very specific:

    cold-outreach: No fabricated mutual connections, referrals, or recruiter commitments.

    line 354
  38. Very specific:

    Run careerrat lint-placeholders <draft-path> if the draft is saved to a file before presenting it to the user.

    line 358
  39. Very specific:

    Sent-clears-draft (hard — same write, no exceptions): When the message was SENT and status advances to waiting (or otherwise past drafted), …

    line 393
  40. Very specific:

    Legacy workspace (no DB): run careerrat tracker --verify.

    line 410
  41. Very specific:

    After every outbound-sent or outbound-draft capture, set nextActionDue:

    line 464
  42. Very specific:

    When the user asks to work follow-ups (or careerrat tracker --followups surfaces them):

    line 472
  43. Distinctive:

    Read the stale thread via STEP 2.

    line 474
  44. Very specific:

    When surfacing currently-due follow-ups (the same set computeFollowUps returns), proactively generate a baked draft for each due item so …

    line 496
  45. Very specific:

    Validate and snapshot: after all drafts are written — same mode branch as STEP 8 items 6–7: DB workspace runs careerrat data verify + …

    line 507

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

email-comms

Runs under AGENTS.md. These contracts bind without being restated here: Privacy Invariant (current_base never outbound), Honesty Firewall, Placeholder/Bracket Ban, Gate Write-back, Domain-Neutral Rule, Browser Automation Contract, Activity Pulse logging, Tracker verify+snapshot, and Sent-Clears-Draft. Inline reminders at point-of-use are intentional; standalone restatements point back to the relevant AGENTS.md section. Bare candidate/, workspace/, config/, and .internal/ paths below are symbolic; resolve them per AGENTS.md's Path Resolution rule.

Agent voice. Read candidate/modes.yml#agent_voice (default standard) before producing in-chat commentary around drafts. Apply the register from AGENTS.md#mode-switches. The drafted artifact itself (email subject + body) is always written in full regardless of register — writing-style.md governs its tone. Register governs the agent commentary: exec-summary = draft only + one action line; standard = draft + short bullets on intent/next-step; technical = draft + thread-context summary + strategy note; verbose = draft + full thread reconstruction + alternatives.

Recipient classes. Employer- and recruiter-facing mail stays draft-only: CareerRat never sends it, and communication.send never reaches a recipient who is not the person. Agent-to-person notifications (to the person whose workspace it is, at the address in their contact record, after they opted in) are a separate class that CareerRat may send, only on the operator's command: careerrat --for <name> roundup --send, or a recruiter-reply draft delivered to them with --deliver-to-person. No opt-in, an unregistered address, or quiet hours means the notification is refused. See AGENTS.md#recipient-classes-hard.

Inputs

  • workspace/tracker.json — communications[] and applications[] arrays
  • workspace/comms/<thread-id>.md — full raw thread body if previously saved
  • candidate/profile.yml — compensation fields, location, domain
  • candidate/targeting.yml — excluded_companies, cut/keep signals
  • candidate/honesty.yml — claims.do_not_fabricate, confirmed tools/claims
  • candidate/evidence.yml — factual sourcing for all claims
  • candidate/writing-style.md — voice and register calibration
  • workspace/writing-samples/ — raw samples for style calibration
  • the latest inbound message text OR the user's stated outbound intent

Outputs

  • complete, finished email or message artifact (subject + body, no placeholders)
  • updated workspace/tracker.json — communications[].messages[] append + status fields
  • optional workspace/comms/<thread-id>.md for long raw thread bodies
  • ~/Documents/careerrat/<Company>/job-<application-id>/<Company> - Email.txt — convenience copy of the draft (workspace/ stays source of truth)
  • tracker snapshot checkpoint

Entry point: Drafting from the Needs Attention surface

When the user pastes or types a prompt of the form:

Draft the follow-up email for {Company} — {Role}

this is the one-click Draft button on the dashboard's Needs Attention panel. It copies exactly that text to the clipboard and the user pastes it here.

Treat this as an outbound-follow-up (or thank-you if the notification kind is thank-you). Start at STEP 1 to match the communications or application record for {Company} + {Role} in workspace/tracker.json. Then run the full flow (STEP 2 → 5 → 6 → STEP 8) to:

  1. Draft the due follow-up (STEP 5 — complete, no placeholders).
  2. Capture the outbound draft message (STEP 6a/b).
  3. Advance the record status and reset nextActionDue (STEP 6b / STEP 8 timer reset).
  4. Persist the baked draft onto the record (STEP 8 — comm.draft or app.followUp.draft) so the notification bell can show it ready to send.
  5. Validate and snapshot — the mode branch from STEP 6(d)/(e) and STEP 8 items 6–7 (DB workspace: careerrat data verify + careerrat tracker --verify; legacy: careerrat tracker --verify then careerrat tracker) — this clears the timer from the Needs Attention panel for that item.

Out-of-band completion (entry-point branch)

Mode detection: run careerrat data status. Exit 0 → DB workspace — every write step below gives the careerrat data <verb> command (Data Write Contract, AGENTS.md). Nonzero exit → legacy workspace (no DB yet) — every write step below gives the existing direct JSON-edit instructions, unchanged.

When the user says they already sent a message, already replied, or already completed the action manually (e.g. "I already sent this", "I replied earlier today", "I did this on my phone"):

  1. Confirm which thread and action the user means (one clarifying question if ambiguous).
  2. Record the completion:
    • Append an outbound-sent (or note if no artifact exists) entry to communications[].messages[] with direction: "outbound-sent", current timestamp, and a one-sentence summary of what was sent.
    • Set status → waiting (or closed if this was a terminal action such as a rejection response or withdrawal).
    • Set nextActionDue = null — the CTA is satisfied.
    • Set nextAction to the next expected event (e.g. "Await recruiter reply"), or null if none.
    • Set comm.draft = null (and app.followUp.draft = null if a draft was staged) — the draft is no longer pending.
    • DB workspace: compose two calls, back-to-back:
      1. careerrat data comm append-message <comm-id> --data '{"direction":"outbound-sent","at":"<ISO>","summary":"<what was sent>"}'
      2. If status is advancing to waiting (the common case): careerrat data comm mark-sent <comm-id> --at <iso> — sets status: waiting, clears comm.draft (and app.followUp.draft when linked), automatically. Otherwise (e.g. closed), or to set nextAction/nextActionDue: read the current row and persist the patched whole row with careerrat data comm upsert --data '<patched full comm row JSON>'.
    • Legacy workspace (no DB): write to tracker.json in one operation — no partial writes.
    • mark-sent also records a verification tier on the write (verified | supervised | user_report); see Conversational workspace handoff below for what each means. The verb derives the tier itself when none is passed: supervised when a CareerRat-prepared draft was in place at send time, user_report when nothing was prepared. Never pass verified yourself; that tier is reserved for a delivery executor with real confirmation evidence.
  3. Validate:
    • DB workspace: careerrat data verify (re-exports + domain integrity), then careerrat tracker --verify (schema-level parity).
    • Legacy workspace (no DB): run careerrat tracker --verify, confirm clean exit, then snapshot (careerrat tracker).

The agent not having performed the action is not a reason to leave the CTA up. Record it immediately and clear state.


Conversational workspace handoff

In the Ask workspace, note capture, drafting, the send handoff, and the sent-report confirm are native: the app runs typed intents (communication.note-request, communication.draft-request, communication.handoff-request, communication.record-external) directly in workspace-agent.mjs, not this skill's step-by-step CLI verbs. A terminal or external-agent run of this skill still follows STEP 1 → 8 and the out-of-band completion branch above exactly as written, issuing the careerrat data comm ... commands directly.

  • Note capture. A free-text request such as "add a note to the Acme thread: called to follow up" resolves the referenced thread and appends a note the same way STEP 6(a) does, then returns a durable communication_note receipt card in the thread. An empty note or an unresolved/ambiguous reference returns specific recovery copy instead of a generic error, never a silent no-op.
  • Send handoff. A request such as "send my reply to Acme" is a pure read; it never sends anything. It resolves the thread, requires an existing draft (same precondition as communication.send), and returns a communication_handoff artifact: a read-only card with the draft's subject/body prefilled into mailto, Gmail, and Outlook compose links built from the thread's first participant email. A thread with no resolvable recipient email returns an explicit no-recipient state instead of a dead link. The card's "I sent this" action runs the same communication.record-external confirm as the out-of-band branch above. Never auto-send stays absolute. No code path in the handoff ever submits, clicks send, or opens a real network connection on the candidate's behalf; it only builds links and waits for the candidate's explicit confirm.
  • Delivering the draft to the person. In a person workspace (--for <name>), careerrat --for <name> comm handoff <id> --deliver-to-person emails the reviewed draft to that person instead of printing compose links. Subject: "Draft reply to <recruiter> for you to send". It goes only to the address in their contact record and only after they opted in; it is never sent to the recruiter, and quiet hours apply (--now overrides). The draft stays on the thread, so the person's later "sent" (careerrat --for <name> comm mark-sent <id>) records the supervised tier.
  • Verification tiers. mark-sent (careerrat data comm mark-sent / the commMarkSent writer) now records one of three tiers on every send, instead of a bare boolean:
    • verified: an executor confirmed delivery. Only communication.send can record this, and it still requires a connected delivery executor (COMMUNICATION_EXECUTOR_UNAVAILABLE if none exists) and now checks the thread's channel first (COMMUNICATION_CHANNEL_UNSUPPORTED for linkedin/phone/sms/portal threads, since those never had a delivery executor to begin with).
    • supervised: CareerRat prepared the draft (via drafting or the handoff above) and the candidate confirmed, out of band, that they sent it. This is what communication.record-external records when a comm.draft was present at confirm time.
    • user_report: a bare self-report with no CareerRat-prepared draft behind it. This is the default when no draft exists, and the tier any unrecognized or omitted value normalizes to, since a caller can never silently upgrade to a stronger claim than it actually made.
  • What's still open. An in-browser compose executor that could reach verified sends directly (instead of requiring the handoff-plus-confirm path) was evaluated and deferred. Its entry criteria: a keystroke-safe design validated against real Gmail/Outlook compose DOMs (webmail send-hotkeys like Ctrl+Enter make "never clicks send" non-trivial), per-account compose/Sent verification for multi-account webmail, and a distinct Sent-folder read consent capability, since today's mail_access is verification-code-only and must not silently widen. Recipient provenance, the remaining blocker at the time this was scoped, is solved by the resolver the handoff uses above.

STEP 0 — Classify message intent

Identify which type of communication this is. The type controls which sub-steps run:

TypeSub-steps triggered
inbound-recruiter-reply2, 3, 4, 5, 6, 7
outbound-reply2, 3, 4, 5, 6, 7
outbound-follow-up2, 3, 4, 5, 6, 8
thank-you2, 3, 4, 5, 6
scheduling-confirm2, 3, 4, 5, 6, 7 — but a true scheduling thread (proposing/confirming/moving a call) routes to schedule-meeting, which owns timezone, double-booking, and calendar holds. Keep this type only for a one-line confirmation inside a broader comms reply.
negotiation-comp2, 3, 4-COMP, 4b, 5, 6, 7
cold-outreach3, 4, 5, 6
note1, 6

If the type is ambiguous, ask one clarifying question before proceeding.


Fake-recruiter check (inbound recruiter and cold-outreach messages)

For an inbound-recruiter-reply or any unsolicited recruiter message, run careerrat recruiter-check --sender <from address> --company-domain <the company's own domain, if known> --posting found|missing [--body-file <message>] --json before drafting. Look the role up on the company's own careers page and pass what you found; omit --posting if you could not check. It is a flag, never a verdict. On suspect:

  • Tell the person plainly, with the RECRUITER SIGNAL: suspect - <signals> line.
  • Always include: "Do not run, install or open any code, repository, archive or file this contact sends until you have confirmed them through the company's own careers page or main switchboard."
  • Draft only a short verify-first reply (ask them to confirm through the company's careers page or from a company address). Share no resume, phone number, ID or payment detail, and never offer to run their code.

STEP 1 — Match to tracker thread

Mode detection (same check as the entry-point branches above): run careerrat data status. Exit 0 → DB workspace — every write step below gives the careerrat data <verb> command (Data Write Contract, AGENTS.md). Nonzero exit → legacy workspace (no DB yet) — every write step below gives the existing direct JSON-edit instructions, unchanged.

Read workspace/tracker.json. Find the communications[] record whose:

  • applicationId matches a known application, OR
  • company + role match, OR
  • threadId or subject matches the inbound message.

If no match and this is a real communication (not a test note):

Create a new communications record with at minimum:

{
  "id": "comm-<applicationId or slug>",
  "applicationId": "<id or null>",
  "company": "...",
  "role": "...",
  "channel": "email|linkedin|portal|phone|sms|other",
  "status": "needs-reply",
  "summary": "<one line>"
}
  • DB workspace: careerrat data comm upsert --data '<the record JSON above>' (full-row insert).
  • Legacy workspace (no DB): write the updated workspace/tracker.json immediately.

Do not proceed with a draft until the record exists.


STEP 2 — Load context

Read each of the following before drafting:

  1. workspace/comms/<thread-id>.md — full thread body and prior exchanges, if the file exists.
  2. The linked applications[] row in workspace/tracker.json — JD context, current status, artifacts.
  3. candidate/evidence.yml — factual claims available to use.
  4. candidate/honesty.yml — boundaries on what may and may not be claimed (claims.do_not_fabricate, tools.do_not_claim).

If the thread is for a role but no applicationId is linked, ask the user to confirm the role before drafting.


STEP 3 — Style gate

Read candidate/writing-style.md.

Then check workspace/writing-samples/ for any files modified AFTER writing-style.md was last written (compare file mtimes; treat absent mtimes as newer).

  • If writing-style.md is absent OR newer samples exist: run npm run calibrate:style, wait for it to finish, then read the updated candidate/writing-style.md before drafting. If it reports "Not calibrated" (no writing samples yet), draft in a plain, neutral voice and ask the user for a few samples; don't treat the style as set.
  • If writing-style.md is current: proceed.

STEP 4 — Comp gate (negotiation-comp messages ONLY)

Read candidate profile config through the shared DB-first accessor and extract:

FieldUse
compensation.target_basePrimary outbound anchor (state this)
compensation.minimum_baseWalk-away floor (do not reveal unless pressed)
compensation.expected_baseForm-field value if a portal comp form is involved (foundations-spec §2 / §5; canonical read path: form-defaults.yml#expected_base first, then profile.compensation.expected_base as fallback)
compensation.cash_over_equityFlag equity vs. cash preference if asked
compensation.current_comp_shareableGate: if false (the default), current_base is PRIVATE

Privacy invariant: current_base must NEVER appear in any outbound draft. If current_comp_shareable is not explicitly true, treat current_base as if it does not exist. State target_base as the anchor; use minimum_base only as an internal walk-away reference, not as an outbound number unless the user explicitly instructs otherwise.

For OE-bucket roles: read profile.compensation.oe_min_base / profile.compensation.oe_max_base (foundations-spec §2; both confirmed in profile.schema.json) and apply them instead of the standard floor/anchor. The standard minimum_base / target_base do NOT apply for OE roles.

If the user states a new comp boundary mid-flow (e.g., "I won't go below $210K on this one") — confirm-first, then run careerrat gate comp-floor <N> --write --confirm. Echo the CLI confirmation.


STEP 4b — Written-negotiation depth (negotiation-comp messages ONLY)

Run this step immediately after STEP 4. It applies the three depth capabilities from the Negotiation Contract in AGENTS.md — read that section first for strategy; this step is written-channel mechanics only.

4b-1 Geographic-discount pushback

If the inbound message cites the candidate's location or remote status to justify a lower offer:

  1. Read the most recent workspace/research/comp-bench-<role>-<loc>-<yyyy-mm>.md artifact. Extract benchmark.floor, benchmark.midpoint, and benchmark.ceiling from frontmatter.
    • If no benchmark artifact exists, do NOT cite a market number. Reframe on value only (see 4b-3). State in the draft that the ask reflects role scope, not a local rate.
    • If the artifact is stale (> 6 months), surface a warning to the user before drafting: Benchmark artifact may be stale — run research-comp to refresh before sending.
  2. Anchor the rebuttal on the role market band, not a locale-discounted figure. Use benchmark.midpoint as the default reference. Use benchmark.floor only when the offer is already above the midpoint and the gap is narrow; use benchmark.ceiling only when the role scope or seniority matches the top of the benchmarked band and the artifact explicitly supports it.
  3. Include a remote-comp-parity statement: distributed roles compete for talent nationally (or globally); the candidate's geography does not reduce role scope or output.
  4. Support with at least one evidence claim from candidate/evidence.yml. Do not fabricate impact metrics.
  5. Never cite current_base. Never cite the discounted-locale figure the employer used — rebut it structurally, not numerically.
Signal in inboundWritten-rebuttal framing
"Our remote employees are at a location-adjusted rate"Cite role market band from benchmark; name the midpoint; assert output-not-location pricing
"Your area has a lower cost of living"Redirect to role scope and national talent pool; use benchmark midpoint as anchor
No benchmark artifact existsValue + role-scope reframe only; no market number

4b-2 Competing-offer / BATNA framing

Read candidate/honesty.yml (claims.do_not_fabricate) before this sub-step. The honesty firewall applies in full.

If the candidate has a real competing offer or genuine alternative:

  1. Name the offer type generically ("another offer at the senior level", not the competing company unless the candidate explicitly instructs disclosure).
  2. State the walk-away clearly — profile.compensation.minimum_base (or oe_min_base for OE roles) is the internal floor; never quote it outbound unless the user explicitly instructs otherwise. Frame it as the candidate's genuine decision timeline.
  3. Frame timeline honestly: state only a deadline that is real. Do not invent urgency. Ask the candidate to confirm the exact date before drafting any sentence that references a deadline.
  4. Use the competing offer as leverage to invite a stronger response, not as a threat. Tone: collegial, not adversarial.

If the candidate has NO competing offer or BATNA:

Do not fabricate one. Say so plainly to the user before drafting: No competing offer or deadline found — coaching to honest alternatives only.

Coach and draft using only:

  • Market anchor from the benchmark artifact (4b-1), if one exists.
  • Unique value from candidate/evidence.yml — measurable impact, rare skills, or domain depth.
  • Time: a genuine response window the candidate controls. Ask the candidate for a specific day and write that real day into the sentence — e.g. "I'd like to give this proper consideration — could we reconnect on Tuesday?" Never emit a bracket placeholder for the date; use the day the candidate actually gives.

Absolute rule (restated from AGENTS.md Negotiation Contract): never fabricate a competing offer, a deadline, a number, or leverage that does not exist.

4b-3 Multi-round sequencing and concession ladder

For ongoing negotiation threads (round ≥ 2), read the full communications[].messages[] history for this thread before drafting. Identify the current round.

Round map:

RoundAnchorToneConcession posture
Openingprofile.compensation.target_base (or oe_max_base for OE roles)Confident, evidence-backedNone — hold the full ask
CounterEmployer's counter vs. target_base deltaCollaborative, value-forwardOffer one tradeable item if needed (see ladder below)
Re-counterClose the remaining gapDecisiveOne final concession, or hold and name a close condition
CloseAt or above minimum_base / oe_min_baseWarm, forward-lookingAccept, decline, or ask for 24 h to decide

Concession ladder — tradeables ranked by profile.compensation.cash_over_equity:

ItemTrade whenDo NOT trade
Sign-on bonusBase is hard-capped; bonus bridges the gapWhen base is already below minimum_base / oe_min_base
Equity / vesting termscash_over_equity is false and the role includes equity compensationWhen cash_over_equity is true
Start dateCandidate has flexibility; small goodwill signalAs a substitute for comp
Title / levelMaterially affects future negotiating power or career pathTo paper over a comp gap
Remote / relocation termsCandidate is indifferent to locationWhen location is a hard constraint
Base salaryOnly as a last move, and only to minimum_base / oe_min_base — never belowNever below the walk-away floor

When cash_over_equity is true, do not offer equity concessions before exhausting sign-on and start-date options.

Deadline discipline:

  • If the employer sets a deadline, acknowledge it and respond within it, or ask for a short extension (24–48 h) once.
  • If no deadline is set, the candidate controls timing. Do not volunteer one.
  • When to hold: round 1 (opening) and any round where the gap to target_base is > 10% — conceding early signals the floor is lower than it is.
  • When to close: the offer clears minimum_base / oe_min_base and any one of (a) the employer states this is final, (b) further rounds risk goodwill, or (c) the candidate's own deadline is real and near.

Persistence: every round's outbound draft is a negotiation-comp message appended to communications[].messages[] via STEP 6. The full round history accumulates in the thread so context is never lost between sessions.

Gate write-back: if a new comp boundary emerges mid-negotiation (e.g., the candidate says "I won't go below $210K on this one"), apply the STEP 4 write-back rule — confirm-first, then run careerrat gate comp-floor <N> --write --confirm for the per-offer walk-away. profile.compensation.minimum_base is the single source of truth for the comp floor — there is no separate targeting.comp_floor field.


STEP 5 — Draft

Write a complete, finished artifact: subject line + body. No placeholders. The artifact must be ready to send as written.

Real names, never placeholders — go generic when unknown. Always sign with the candidate's real name (candidate/profile.yml#candidate.preferred_name, falling back to full_name) and a real signature block — the candidate is always known, so the signature is never generic. Address the recipient by their real name and pull company/role/contact from the linked tracker.json record. If a detail is genuinely unknown, write it generically — NEVER emit a bracketed token like [Name], [Recruiter], or [Company], and never block or ask the user to fill one. An unknown recipient name → a natural greeting (Hi, / Hello,; use Hi {team} team only if the team is actually known). An unknown company/role → rephrase the sentence so it reads naturally without it. Brackets in the output are a build failure, not a TODO.

Apply writing-style.md register and patterns throughout. Match the tone and formality of the inbound thread.

Per-type requirements:

  • thank-you: Reference one specific moment from the interview or conversation for genuine personalization. Read workspace/tracker.json#applications[].conversations[] for notes.
  • scheduling-confirm: State candidate availability from profile.yml#location / travel_tolerance. Confirm the channel (email vs. portal vs. video link). Do not confirm logistics that haven't been stated.
  • negotiation-comp: State target_base as the anchor. Do NOT mention current_base. Support the anchor with value framing from evidence.yml. Apply the full STEP 4b written-negotiation depth: geographic-discount rebuttal (4b-1), competing-offer/BATNA framing (4b-2), and concession-ladder round sequencing (4b-3). See AGENTS.md Negotiation Contract for strategy; STEP 4b for written-channel mechanics.
  • cold-outreach: No fabricated mutual connections, referrals, or recruiter commitments. Claims sourced from evidence.yml only.
  • follow-up: Mirror prior thread tone. Do not re-litigate prior exchanges; advance to next action.
  • note: Factual summary only, no outbound artifact.

Run careerrat lint-placeholders <draft-path> if the draft is saved to a file before presenting it to the user.

For outreach and any draft that states candidate facts, also run careerrat fact-check <draft-path> --company "<Company>". Exit 1 means the draft names a number, tool, employer or person absent from the candidate's résumé and evidence: remove it or ask the user before presenting.


STEP 6 — Capture

Execute the following mutation sequence in order:

(a) Append the new message to communications[].messages[] in workspace/tracker.json:

{
  "id": "msg-<commId>-<n>",
  "direction": "inbound|outbound-sent|outbound-draft|note",
  "at": "<ISO 8601 datetime>",
  "from": "<sender>",
  "to": ["<recipient>"],
  "subject": "<subject line>",
  "summary": "<concise gist — one sentence>",
  "artifactPath": "<workspace/comms/... if saved>"
}
  • DB workspace: careerrat data comm append-message <comm-id> --data '<the message JSON above>'. This appends to messages[] and auto-rolls lastInboundAt/lastOutboundAt forward from direction — the first half of (b) below. Include a summary or nextAction key in the payload if you also want those set on the parent record in this same call (the verb applies them when present).
  • Legacy workspace (no DB): append to communications[].messages[] directly in workspace/tracker.json.

(b) Update the parent communications record with:

  • status: needs-reply | drafted | waiting | scheduled | closed | blocked
  • nextAction: what to do next
  • nextActionDue: ISO date
  • lastInboundAt or lastOutboundAt: ISO datetime of this message

    Sent-clears-draft (hard — same write, no exceptions). When the message was SENT and status advances to waiting (or otherwise past drafted), also set comm.draft = null on the communications record in this same write. If the draft came from app.followUp.draft, set app.followUp.draft = null (or remove app.followUp). This is the sent-clears-draft invariant from AGENTS.md "Actionability Write-Back Contract". Leaving a draft set after a send creates a ghost "Ready to send" panel that never resolves.

    • DB workspace: compose the call(s) matching what actually changed, back-to-back with (a) above (no combined messages+status transaction exists):
      • Message was SENT and status is advancing to waiting (the common send-clears-draft case): careerrat data comm mark-sent <comm-id> [--at <iso>] [--summary "<t>"] — sets status: waiting, clears comm.draft (and app.followUp.draft when that application exists), and stamps lastOutboundAt, all in one transaction, automatically. Do not hand-write that clear.
      • Any other status value (needs-reply, drafted, scheduled, closed, blocked) or to set nextAction/nextActionDue explicitly: there is no single-field patch verb for communications[], so read the current row from workspace/tracker.json#communications[], apply the fields above (carrying every other field over unchanged), and persist the whole row: careerrat data comm upsert --data '<patched full comm row JSON>'.
    • Legacy workspace (no DB): edit the fields above directly on the communications record in workspace/tracker.json, in the same write as (a).

(c) Save long raw body to workspace/comms/<thread-id>.md if the body exceeds one paragraph. Reference the path in artifactPath. workspace/comms/ files are local-only and must not appear in any outbound artifact. (This is a local file write outside tracker.json — unaffected by DB vs legacy mode.)

(d) Validate:

  • DB workspace: the calls in (a)/(b) already persisted and auto-exported workspace/tracker.json + workspace/activity.jsonl (Data Write Contract, AGENTS.md). Run careerrat data verify (re-exports + domain integrity) and careerrat tracker --verify (schema-level parity).
  • Legacy workspace (no DB): run careerrat tracker --verify.

Confirm it exits clean before proceeding. If it fails, fix the JSON (legacy) or the DB row via another verb call (DB — never hand-edit tracker.json, it is a regenerated file) and re-run.

(e) Snapshot:

  • DB workspace: already exported by the calls above; if careerrat tracker-dev is running its fs.watch already picked it up. Run careerrat tracker only for a recovery checkpoint.
  • Legacy workspace (no DB): run careerrat tracker.

Then log it to the Activity Pulse feed (the dashboard's live timeline — see Activity Pulse in AGENTS.md). If the message is a draft awaiting the user to send, log it as needing the user; if it was already sent, log it sent:

  • DB workspace: the (a)/(b) calls above already auto-logged their own generic events (message type) in their own transactions. For the richer, outcome-specific type below, log an additional event (this verb only logs, it never bumps the stamp):

    # draft awaiting send:
    careerrat data activity append --data '{"type":"drafted","actor":"agent","needsUser":true,"title":"Drafted reply — <Company>","summary":"<one line: what the message does>","refs":{"applicationId":"<application id>","company":"<Company>"},"cta":{"label":"Review & send"}}'
    
    # already sent:
    careerrat data activity append --data '{"type":"message","actor":"agent","title":"Sent — <Company>","summary":"<one line: what the message does>","refs":{"applicationId":"<application id>","company":"<Company>"}}'
  • Legacy workspace (no DB):

    # draft awaiting send:
    careerrat activity append --type drafted --actor agent --needs-user \
      --title "Drafted reply — <Company>" --summary "<one line: what the message does>" \
      --company "<Company>" --app-id <application id> --cta-label "Review & send" --write
    
    # already sent:
    careerrat activity append --type message --actor agent \
      --title "Sent — <Company>" --summary "<one line: what the message does>" \
      --company "<Company>" --app-id <application id> --write

STEP 7 — Outcome routing

Branch on message type after capture:

ConditionActionDB-mode command
Scheduling confirmed / interview bookedIn the SAME tracker.json write: update applications[].status to the appropriate stage (phone-screen, onsite, etc.) AND update the communications record — status → scheduled, nextActionDue = null, nextAction = 'Attend <stage> — <date>' (or clear it if no further prep action is needed), comm.draft = null if a draft was staged. Both records must land in one write so neither leaves a ghost CTA. Hand off to interview-prep for prep materials.careerrat data app set-status <id> "<stage>" for the application row, then patch the comm record (read current row, apply the fields, persist): careerrat data comm upsert --data '<patched full comm row JSON>'. Run both back-to-back, never deferring the second.
Offer receivedBefore surfacing the comp comparison, write to tracker.json in one operation: comm status → waiting, nextActionDue = null, nextAction = 'Evaluate offer and respond' (set a real deadline if the employer gave one, else null), comm.draft = null if a draft was staged. Append a note-direction message to messages[] summarising the offer receipt. Then read the candidate config through the shared DB-first accessor (profile.compensation.minimum_base and target_base), surface whether the stated comp clears the floor, and recommend accept/negotiate/decline. Do not accept on the user's behalf.Patch the comm record locally (status/nextActionDue/nextAction/draft), persist: careerrat data comm upsert --data '<patched full comm row JSON>'; then append the note: careerrat data comm append-message <comm-id> --data '{"direction":"note","at":"<ISO>","body":"<offer receipt summary>"}'. Comp comparison itself is a candidate-config read: SQLite in DB mode, YAML only in legacy mode.
Rejection or withdrawalIn the SAME tracker.json write as the STEP 6 capture: set status = closed, nextActionDue = null, nextAction = null, comm.draft = null (if any draft was staged). Do not rely on STEP 6's generic status field alone — state it explicitly here. Then hand off to track-outcomes for durable outcome recording and reevaluation-threshold check.Same composition as STEP 6(b)'s DB branch — read-patch-persist careerrat data comm upsert --data '<patched full comm row JSON>' with status: "closed", nextActionDue: null, nextAction: null, draft: null. track-outcomes owns the durable app-row transition (its own DB verbs) — this call only closes the comm thread.
Ghosting / no response after follow-upIn the SAME tracker.json write: set status = closed (or blocked if appropriate), nextActionDue = null, nextAction = null, comm.draft = null if a draft was staged. The due-date CTA must clear together with the status in one write. Hand off to track-outcomes.Same read-patch-persist pattern: careerrat data comm upsert --data '<patched full comm row JSON>' with the fields above.
User states new exclusion mid-thread (e.g., "never email this company again")Run careerrat gate exclude-company "<Company>" --write; it applies directly and is logged with an Undo. Echo the CLI result. Run careerrat doctor to verify after write.careerrat gate is DB-aware: it writes SQLite in DB mode and legacy YAML only in legacy mode. Never hand-edit candidate/targeting.yml.
User states new comp floor mid-threadSee STEP 4 comp write-back rule; use careerrat gate comp-floor <N> --write --confirm.careerrat gate is DB-aware: it writes SQLite in DB mode and legacy YAML only in legacy mode. Never hand-edit candidate/profile.yml.

STEP 8 — Follow-up timer management

After every outbound-sent or outbound-draft capture, set nextActionDue:

  • Waiting for recruiter reply: 5–7 business days from send date.
  • Awaiting scheduling follow-up: 2 business days before the event deadline (or 3 days if no deadline given).
  • Thank-you: no follow-up needed unless the user requests one. After the thank-you is captured via STEP 6, set nextActionDue = null on the comm record in that same write — the thank-you satisfies the due event. Do NOT set a new follow-up timer unless the user explicitly asks for one.

Stale threshold: A waiting thread with no update for 7 days is stale and surfaces as a follow-up. Applied-with-no-response threads surface after 10 days. These defaults come from cadence.mjs — if the candidate's follow_up: config block in candidate/targeting.yml provides custom thresholds for a kind, those take precedence over the code defaults.

When the user asks to work follow-ups (or careerrat tracker --followups surfaces them):

  1. Read the stale thread via STEP 2.
  2. Apply STEP 3 style gate.
  3. Draft the follow-up (STEP 5).
  4. Capture (STEP 6): append outbound message, advance status from waiting to appropriate state, reset nextActionDue.
  5. Persist a baked draft for the dashboard notification panel. After drafting, write draft: { subject, body } onto the relevant record in workspace/tracker.json:
    • For comm-based kinds (needs-reply, comm-due, waiting-stale): set comm.draft = { subject, body } on the communications record.
    • For application-based kinds (app-nudge, post-interview-nudge, thank-you): set app.followUp = { kind, dueAt, draft: { subject, body }, generatedAt } on the application record. The dashboard notification bell reads these fields directly; a baked draft appears as "ready to send." A baked draft is only valid for the DRAFT/awaiting-send state; once the message is sent, it must be nulled per the sent-clears-draft invariant (STEP 6b) so the "Ready to send" panel clears.
    • DB workspace: comm-based kinds — there is no single-field patch verb for communications[], so read the current row, set draft, and persist: careerrat data comm upsert --data '<patched full comm row JSON>'. Application-based kinds — app.followUp merges one level under app set-fields: careerrat data app set-fields <id> --data '{"followUp":{"kind":"<kind>","dueAt":"<iso>","draft":{"subject":"<s>","body":"<b>"},"generatedAt":"<iso>"}}'. Neither is outcome-changing.
    • Legacy workspace (no DB): edit workspace/tracker.json directly. Also write a convenience copy to the job's Documents folder — ~/Documents/careerrat/<Company>/job-<application-id>/<Company> - <what> Email.txt (e.g. Aperture Science - Follow-up Email.txt), per the Artifact Contract (organized by company, job, then round). Use the real company name for the folder and file (never a bracket placeholder; if the company is somehow unknown, use unknown). File content: subject line on the first line, a blank line, then the body. workspace/ stays the source of truth; Documents is for the user's convenience. Body must NEVER contain current_base or any private comp field (per the Privacy Invariant). (This Documents copy is a local file write outside tracker.json — unaffected by DB vs legacy mode.)
  6. Validate:
    • DB workspace: the calls above already persisted and auto-exported. Run careerrat data verify and careerrat tracker --verify.
    • Legacy workspace (no DB): careerrat tracker --verify (also: npm run verify:tracker). Confirm clean exit before proceeding.
  7. Snapshot:
    • DB workspace: already exported; run careerrat tracker only for a recovery checkpoint.
    • Legacy workspace (no DB): careerrat tracker (so the timer clears from the dashboard).

STEP 8a — Prepare follow-up drafts

When surfacing currently-due follow-ups (the same set computeFollowUps returns), proactively generate a baked draft for each due item so the bell panel shows them ready to send — not just as bare reminders.

For each item returned by computeFollowUps:

  1. Check the candidate follow_up: config in candidate/targeting.yml. Do NOT generate a draft for any kind that is enabled: false — those reminders are suppressed entirely. Use the configured after_days / after_hours thresholds (mapped via rulesFromConfig in src/core/tracker/cadence.mjs) rather than hardcoded defaults.
  2. Draft per kind (apply STEP 5 requirements — complete, no placeholders, honest):
    • app-nudge: short, professional follow-up on the outstanding application; reference the role name and applied date. Contact-path precondition (hard): an app-nudge only makes sense when there's an actual person to nudge — a linked recruiter/email thread (communications[] for the row), a logged conversation with a named who, or an explicit contact on the application. A black-hole portal or cold application with none of these has nobody to follow up with, so computeFollowUps no longer emits an app-nudge for it (see appHasContactPath in cadence.mjs). Do not hand-draft a follow-up "to nobody" for such a row — the real move is relationship-sourcing (find a contact first) or a plain wait/archive decision. The dashboard mirrors this: a contactless quiet application shows no action.
    • post-interview-nudge: 4–6 lines referencing the interview stage, reiterating interest, and asking about next steps.
    • thank-you: warm, specific thank-you referencing one detail from applications[].conversations[]; keep to 3–5 lines.
    • needs-reply / comm-due / waiting-stale: advance the existing thread per STEP 5's follow-up guidance.
  3. Persist the draft to the record (see STEP 8 — "Persist a baked draft" above, including its DB-mode branch).
  4. Validate and snapshot after all drafts are written — same mode branch as STEP 8 items 6–7: DB workspace runs careerrat data verify + careerrat tracker --verify; legacy workspace runs careerrat tracker --verify then careerrat tracker.

HONESTY

See Negotiation Contract › Honesty Firewall (hard) in AGENTS.md. Read candidate/honesty.yml before every draft.


Known limitations

  • Cadence is candidate-configurable via follow_up: in candidate/targeting.yml. STEP 8's timing defaults come from src/core/tracker/cadence.mjs (FOLLOWUP_RULES), but a candidate can override any kind's enabled flag and after_days / after_hours threshold via a follow_up: block in candidate/targeting.yml — the schema for this block is defined in config/targeting.schema.json and the snake_case → camelCase mapping is handled by rulesFromConfig() in cadence.mjs. Supported kinds: app_nudge, post_interview, waiting_stale, interview_thank_you, needs_reply, comm_due. Two kinds are new as of the current cadence engine: post-interview-nudge (interviewed but gone quiet, default 5 days) and thank-you (owed within 24 hours of a logged interview). Always check whether a kind is enabled before preparing or surfacing a draft for it.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.