apply
免费Searches for jobs on LinkedIn, filters by profile Must-haves, applies via Easy Apply, registers each application in DB.
中文简介正在准备中。当前显示原文说明。
Reviews job application updates from Gmail, LinkedIn and platforms. Prepares drafts, presents executive summary by priority, validates with the user and sends replies.
中文简介正在准备中。当前显示原文说明。
实测评分 未实测
查看安装方法安装前可以查看交给 AI 代理的指令内容。
Keyword: news
The user says news (or variants: "updates", "check", "any updates") and the full review routine is automatically triggered. No further instructions needed — the agent executes the entire flow from start to finish.
Also runs in parallel when the user launches an application.
node .agents/skills/browser-automation/scripts/browser.js open <url> --headed (Gold Rule 5) → notify user → wait for confirmationnode .agents/skills/browser-automation/scripts/browser.js for open/close/goto. See AGENTS.md "Browser session" for details. Never call agent-browser open directly, never open Chrome directlymemory skill):
node scripts/db.js "SELECT category, key, value, confidence, source FROM preferences WHERE user_id = <user_id> AND status = 'active' ORDER BY category, key"
node scripts/db.js "SELECT data->'strategy' AS strategy FROM users WHERE id = <user_id>"
Respect: follow_up_days (days before sending follow-up), cold_outreach (whether to send cold messages to recruiters). If news not in sources_active, warn the userParallelization strategy: when subagents are available, dispatch background subagents (subagent_general) per source to collect updates simultaneously. Each subagent returns a structured list of items (sender, subject, snippet, category guess, action items, scheduling links if any). The main agent then merges and classifies. If subagents are not available (e.g: single-session constraint), fall back to sequential collection.
Subagent dispatch pattern:
┌─────────────────────────────────────────────────────┐
│ Main agent (orchestrator) │
│ - Loads preferences, strategy, availability │
│ - Dispatches subagents in parallel │
│ - Merges results, classifies, presents summary │
├─────────────────────────────────────────────────────┤
│ Subagent A (Gmail) Subagent B (LinkedIn) │
│ - Inbox unread - Messages unread │
│ - Job Alerts folder - Notifications │
│ - Extract sched links - Saved Jobs │
│ - Returns JSON list - Returns JSON list │
├─────────────────────────────────────────────────────┤
│ Subagent C (DB) Subagent D (Sched links) │
│ - Pending follow-ups - Opens each Calendly/SR │
│ - Pipeline stages - Filters by availability │
│ - Returns JSON list - Returns slot table │
└─────────────────────────────────────────────────────┘
Important: to avoid conflicts between subagents, give each one its own agent-browser --session (sessions are created implicitly by the flag — see AGENTS.md "Parallel execution"):
node .agents/skills/browser-automation/scripts/browser.js open <url> then run all its commands via exec <cmd> --session news-gmail--session news-linkedintab-new) if subagents can't use separate sessionsscripts/db.js--session news-schedexec close --session news-gmail for that session only — never close-all, which kills the browser for other agents)If subagents are NOT available (e.g: tool not supported, single foreground agent constraint), fall back to sequential collection as before. The flow must work in both modes.
Subagent prompt template (adapt per source):
You are a job search assistant. Collect updates from <source> and return a structured list.
Context:
- Last review: <last_review_at>
- User profile: <profile summary from DB>
- Strategy: <strategy level and params>
Instructions:
1. Open <url> using: node .agents/skills/browser-automation/scripts/browser.js open <url> (from the repo root directory)
2. <source-specific steps: read unread messages, extract sender/subject/snippet/date>
3. For each item, identify: sender, subject, date, snippet (first 200 chars), category guess (interview/offer/recruiter_new/recruiter_reply/rejected/newsletter/new_job), action items (calendar link? CV requested? form to fill?), and any scheduling URLs
4. Return a markdown table with all items found. Do NOT reply to anything, do NOT archive, do NOT click scheduling links (just extract the URL)
5. Close the tab when done
Return format:
| # | Sender | Subject | Date | Category | Action items | Scheduling URL |
Sources to collect (dispatch as parallel subagents when possible, sequential otherwise):
last_review_at to DB to know since when to searchJob Alerts folder: check Job Alerts label (alerts from platforms configured via radar skill). Classify each alert by fit: Must/Strong/Nice per PROFILE.md. Only present Must and Strong in the summary. Ignore Nice unless user asks to see allhttps://www.linkedin.com/my-items/saved-jobs/. For each saved job: check if still open, evaluate fit against profile (Must/Strong/Nice), check if already applied (query DB by URL or company+role). Present Must/Strong matches in summary as new_job_must/new_job_strong. If user already applied, skip. If job is closed, mark as closed and remove from savedreferrals flow (or by step 2.5 of apply/targets) that are still pending user approval:
node scripts/db.js "SELECT id, channel, sender, subject, draft, data FROM messages WHERE user_id = <user_id> AND status = 'draft' AND direction = 'outbound' AND (data->>'category') IN ('referral_request', 'recruiter_outreach') ORDER BY received_at DESC"
For each staged draft: present it in the summary under its category (referral_request or recruiter_outreach) with the contact name, company, and the draft text. User can approve (send via LinkedIn DM), edit, or reject. This is where warm-sourcing drafts become actionable — the referrals flow stages them, news surfaces them for approval and sends them.subagent_general) to open each link, read available slots, and filter them against users.data.availability (preferred_hours, timezone, blocked days). The subagent returns a filtered list of slots that match the user's preferences. This runs in parallel with the rest of the news flow so the user doesn't wait. The subagent prompt must include:
node .agents/skills/browser-automation/scripts/browser.js open <url>, take snapshot, extract all available time slots, filter by preferred_hours and blocked days, return a markdown table of matching slots sorted by day then timeEach item is classified into a category and assigned contextual priority:
| Category | Description | Default priority |
|---|---|---|
interview | Interview invitation, scheduling | High |
offer | Job offer, salary proposal | High |
recruiter_new | New recruiter outreach (no prior application) | Medium |
recruiter_reply | Recruiter reply to an application | Medium |
referral_request | Staged referral request draft awaiting approval (from referrals flow) | Medium-High |
recruiter_outreach | Staged cold recruiter outreach DM awaiting approval (from referrals flow) | Medium |
follow_up | Application without response, needs following up | Medium-Low |
rejected | Application rejection | Low |
new_job_must | New job matching Must-have | Medium-High |
new_job_strong | New job matching Strong | Medium |
new_job_nice | New job matching Nice | Low |
newsletter | Newsletter with relevant jobs | Low |
Contextual priority adjusts based on:
Gold Rule 6: ALWAYS show draft to user before sending. Never send without approval.
For each item that requires a response:
Draft types:
referrals flow — present as-is for approval. If user edits, update the messages.draft field before sending. Send via LinkedIn DM to the contact's vanity. Pass Gold Rule 7 checklist before showing.referrals flow — present as-is for approval. If user edits, update messages.draft before sending. Send via LinkedIn DM. Pass Gold Rule 7 checklist before showing.Drafts are saved to messages.draft as JSONB.
Present to user ordered by priority (high → low).
Template for promising proposal (new recruiter outreach with JD + action items):
🎉 Promising proposal: [Role] at [Company]
Company: [what they do, size, funding, partners, expansion]
Fit: [Excellent/Good/Fair. Why]
Reports to: [CTO/VP Eng/etc]
Compensation: [Not mentioned / $X]
Work mode: [Not mentioned / Remote / Hybrid]
📆 Calendar to book: [link]
📎 JD: Read ([key points of the role in 1 line])
Want me to research more about [Company]?
Want me to prepare a reply draft?
Format for remaining items (ordered by priority):
## Updates summary (12 items)
### High priority (3)
1. [interview] Google - Engineering Manager AI - Technical interview Tuesday 15:00
→ Draft: confirm + propose time slots
→ [Approve] [Edit] [Reject]
2. [offer] Stripe - $7k/mo - Offer with 0.1% equity
→ Draft: thank + ask for details
→ [Approve] [Edit] [Reject]
3. [interview] Remote - AI Strategy Lead - Recruiter screening Thursday
→ Draft: confirm + propose time slots
→ [Approve] [Edit] [Reject]
### Medium priority (5)
4. [recruiter_new] Meta - Recruiter outreach for Staff EM
→ Draft: express interest
→ [Approve] [Edit] [Reject]
...
### Low priority (4)
10-12. [rejected] 3 rejections (Mercado Libre, Globant, Bumeran)
→ [Batch: thank all] [Ignore]
13. [newsletter] Get on Board - 15 new jobs this week
→ [View jobs] [Ignore]
ignored in DBmessages.sent_at, messages.status = sent)last_review_at in DBpipeline.js --move <id> <stage> instead of direct UPDATE to keep the audit trail in data.stage_history. Canonical stages: discovered, contacted, applied, in_review, screening, interview, offer, hired, rejected, withdrawn, skipped. See AGENTS.md "Pipeline kanban"All DB access via scripts/db.js (see db skill). Read-only by default, --write for inserts/updates.
Schema (create on first run with --write):
node scripts/db.js "CREATE TABLE IF NOT EXISTS applications (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), platform TEXT, company TEXT, role TEXT, url TEXT, status TEXT DEFAULT 'applied', applied_at TIMESTAMPTZ DEFAULT NOW(), data JSONB DEFAULT '{}')" --write
node scripts/db.js "CREATE TABLE IF NOT EXISTS messages (id SERIAL PRIMARY KEY, application_id INTEGER REFERENCES applications(id) NULL, user_id INTEGER REFERENCES users(id), channel TEXT, direction TEXT, sender TEXT, subject TEXT, body TEXT, draft TEXT, status TEXT DEFAULT 'pending', received_at TIMESTAMPTZ, sent_at TIMESTAMPTZ, data JSONB DEFAULT '{}')" --write
Typical queries:
# Pending follow-ups
node scripts/db.js "SELECT id, company, role, applied_at FROM applications WHERE user_id = <user_id> AND status = 'applied' AND applied_at < NOW() - INTERVAL '5 days'"
# Save a draft
node scripts/db.js "INSERT INTO messages (user_id, channel, direction, sender, subject, body, draft, status, received_at) VALUES (<user_id>, 'gmail', 'inbound', '<sender>', '<subject>', '<body>', '<draft>', 'draft', NOW())" --write
# Mark sent
node scripts/db.js "UPDATE messages SET status = 'sent', sent_at = NOW() WHERE id = <id>" --write
# Update last_review_at
node scripts/db.js "UPDATE users SET data = jsonb_set(data, '{last_review_at}', '\"<iso>\"') WHERE id = <user_id>" --write
users.data.last_review_at (JSONB) tracks the last review timestamp.
style_profile from DBlast_review_at in users.data to know since when to searchrefs change after every action. ALWAYS take a new snapshot before interactinglinkedin.com/messaging/ → snapshot → look for unread conversationslinkedin.com/notifications/ → snapshotmcp6_send_message and mcp6_connect_with_person require confirm_send: true (if using LinkedIn MCP)Use this instead of opening conversations one by one. One HTTP call returns ALL conversations with participants, unread count, last message preview, and last activity timestamp.
Script: scripts/linkedin-inbox.js — run via:
node .agents/skills/browser-automation/scripts/browser.js tab-new "https://www.linkedin.com/messaging/" --name linkedin
sleep 3
node .agents/skills/browser-automation/scripts/browser.js exec eval --tab linkedin "$(cat scripts/linkedin-inbox.js)"
Returns JSON array: [{participants, unreadCount, lastActivityAt, lastMessage: {text, deliveredAt, isFromSelf}, threadId}, ...]
How it works:
GET /voyager/api/voyagerMessagingGraphQL/graphql?queryId=messengerConversations.<ID>&variables=(mailboxUrn:urn%3Ali%3Afsd_profile%3A<selfId>)csrf-token (from JSESSIONID cookie, stripped of quotes), accept: application/vnd.linkedin.normalized+json+2.1, x-restli-protocol-version: 2.0.0page.evaluate() so it has all cookies automatically (no need to extract li_at)performance.getEntriesByType('resource') and grep for messengerConversationsdocument.documentElement.outerHTML.match(/ACoAA[A-Za-z0-9_-]{5,}/g) (most frequent match)Decision logic after fetch:
unreadCount > 0 → open that thread in UI to read full message and respondlastMessage.isFromSelf === true → waiting for reply, no action neededlastMessage.isFromSelf === false AND unreadCount === 0 → already read but no reply sent (may need follow-up)lastActivityAt → compare against last_review_at to detect new activity since last runthreadId → extract the 2-XXXXX part for direct navigation: https://www.linkedin.com/messaging/thread/<threadId>/Important: The query ID (messengerConversations.0d5e6781bbee71c3e51c8843c6519f48) was valid as of Aug 2026. If the endpoint returns HTML instead of JSON, the query ID is stale. Re-extract it from performance.getEntriesByType('resource') after loading the messaging page.
Use this to send messages without opening the compose UI. Supports text-only and text+attachment, new conversations and replies.
Script: scripts/linkedin-send.js — unified script. Edit MODE, RECIPIENT_ID/THREAD_ID, MESSAGE_TEXT, and FILE_B64 (base64-encoded file, empty = no attachment), then:
node .agents/skills/browser-automation/scripts/browser.js exec eval --tab linkedin "$(cat scripts/linkedin-send.js)"
Text-only — new conversation (legacy endpoint):
POST /voyager/api/messaging/conversations?action=createcsrf-token, x-restli-protocol-version: 2.0.0, accept: application/vnd.linkedin.normalized+json+2.1, content-type: application/json{"keyVersion":"LEGACY_INBOX","conversationCreate":{"eventCreate":{"value":{"com.linkedin.voyager.messaging.create.MessageCreate":{"attributedBody":{"text":"...","attributes":[]},"attachments":[]}}},"recipients":["<fsd_profile_id>"],"subtype":"MEMBER_TO_MEMBER"}}{"data":{"value":{"createdAt":...,"conversationUrn":"urn:li:fs_conversation:2-...","backendConversationUrn":"urn:li:messagingThread:2-..."}}}2-XXXXX part of conversationUrn is the THREAD_ID for future repliesText-only — reply (legacy endpoint):
POST /voyager/api/messaging/conversations/{chatId}/events?action=createchatId = the 2-XXXXX part of the threadId (URL-encoded){"eventCreate":{"value":{"com.linkedin.voyager.messaging.create.MessageCreate":{"attributedBody":{"text":"...","attributes":[]},"attachments":[]}}}}With attachment — dash endpoint (WORKS, tested Aug 2026):
POST /voyager/api/voyagerMessagingDashMessengerMessages?action=createMessagecsrf-token, x-restli-protocol-version: 2.0.0, accept: application/json, content-type: text/plain;charset=UTF-8 (NOT application/json)conversationUrn format: urn:li:msg_conversation:(urn:li:fsd_profile:<self_id>,<thread_id>)trackingId: 16 random bytes as string (binary, NOT a string UUID)originToken: standard UUID v4{
"message": {
"body": {"attributes": [], "text": "..."},
"renderContentUnions": [
{
"file": {
"assetUrn": "urn:li:digitalmediaAsset:...",
"byteSize": <bytes>,
"mediaType": "application/pdf",
"name": "filename.pdf",
"url": "blob:https://www.linkedin.com/<uuid>"
}
}
],
"conversationUrn": "urn:li:msg_conversation:(urn:li:fsd_profile:<self_id>,<thread_id>)",
"originToken": "<uuid-v4>"
},
"mailboxUrn": "urn:li:fsd_profile:<self_id>",
"trackingId": "<16 binary bytes>",
"dedupeByClientGeneratedToken": false
}
url field is a blob: URL created via URL.createObjectURL(fileBlob). It is required by the API even though it's a client-side URL.renderContentUnions, NOT in attachments. The attachments field is silently dropped by the dash endpoint.{"value":{"entityUrn":"urn:li:msg_message:...","backendConversationUrn":"urn:li:messagingThread:...","deliveredAt":...,"renderContentUnions":[{"file":{...}}]}}Recipient format: fsd_profile_id (e.g. ACoAA...). Extract from profile page HTML: document.documentElement.outerHTML.match(/ACoAA[A-Za-z0-9_-]{5,}/g) (most frequent match, excluding self).
Self profile ID: same regex, most frequent match in the page HTML.
Upload flow (3 steps):
Step 1 — Register upload:
POST /voyager/api/voyagerVideoDashMediaUploadMetadata?action=upload{"mediaUploadType":"MESSAGING_FILE_ATTACHMENT","fileSize":<bytes>,"filename":"<name>"}{"data":{"value":{"urn":"urn:li:digitalmediaAsset:...","mediaArtifactUrn":"urn:li:digitalmediaMediaArtifact:(...)","singleUploadUrl":"https://www.linkedin.com/dms-uploads/...","pollingUrl":"https://www.linkedin.com/dms/processStatus/..."}}}Step 2 — Upload binary:
PUT to singleUploadUrl with Content-Type: application/pdf (or appropriate MIME) and raw file bytes as bodyStep 3 — Wait for processing:
GET the pollingUrl until status["urn:li:digitalmediaRecipe:messaging-document"] is AVAILABLEAVAILABLE before sending — the dash endpoint accepts it immediately after uploadScript: scripts/linkedin-upload.js — register-only script (step 1). For the full flow (register + upload + send), use scripts/linkedin-send.js with FILE_B64 set.
If the endpoint flow fails (e.g. LinkedIn changes the dash schema), use the UI:
button "Attach a file to your conversation with <name>" refnode .agents/skills/browser-automation/scripts/browser.js exec upload "<file_path>" --tab linkedin[cursor=pointer])Attach buttons only appear when a conversation is open (not in the messaging overlay/list view). Navigate to a specific thread first.
fill with the textbox ref works for writing text into tiptap. Example: node .agents/skills/browser-automation/scripts/browser.js exec fill @e770 "message text" --tab linkedin. This is the reliable methodtype without a ref does NOT work — it types into whatever is focused but tiptap's contenteditable doesn't always receive it. Always use fill <ref> insteadtype <text> (positional, no ref) fails with multiline text — the CLI parses it as multiple arguments. Never use type for LinkedIn messages@eN [paragraph] with no text). To verify text was entered, use eval: node .agents/skills/browser-automation/scripts/browser.js exec eval --tab linkedin "document.querySelector('[contenteditable=true]')?.innerText?.substring(0,300)"disabled, becomes enabled ([cursor=pointer]) when there is text OR an attachment. Check with grep: exec snapshot -i | grep -i sende860, e861) are NOT stable — they change after every action and clicking them often fails with "Ref not found". Always interact with the textbox ref itself, not its children[File chooser]: can be handled by upload) → use node .agents/skills/browser-automation/scripts/browser.js exec upload 'input[type="file"]' "/path/to/file.pdf" --tab linkedinfigure "filename.pdf • XXX KB Attached" with a heading and a remove buttoneval) and attachment (via snapshot grep) before clicking Sendparagraph under each heading level=3). "You:" prefix means the user sent the last message (waiting for reply). Sender name without "You:" means they sent last (may need action)https://www.linkedin.com/messaging/thread/2-XXXXX/ — the ID is stable per conversation and can be stored in DB for direct navigationexec upload 'input[type="file"]' "/path/to/file.pdf" → attachment appears in snapshot@e77)exec fill @e77 "message text" (use \n for line breaks, works in tiptap)eval (innerText)disabledSMTP via nodemailer is the preferred method for sending outbound email. It has no UI dependency, is faster, and more reliable than browser automation.
.env (never commit .env):
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=you@example.com
SMTP_PASS=your-app-password
node scripts/send-email.js --to someone@example.com --subject "Subject" --body "Message"
sendEmail() from scripts/send-email.js and mark outreach_contacts.status = 'contacted' after successful delivery.The browser CAN send emails. Compose is div[role=dialog] in the main document and all elements are accessible via agent-browser (exec commands).
When to use browser email:
.env app password)When NOT to use browser email:
scripts/send-email.js -- Send emails via SMTP (preferred)Uses nodemailer with SMTP app password. No browser dependency. Faster and more reliable than gmail-send.js.
node scripts/send-email.js --to someone@example.com --subject "Subject" --body "Message"
Requires SMTP credentials in .env (see "Email delivery" section above).
scripts/linkedin-send.js -- Send LinkedIn messages with optional attachmentsUnified CLI for sending LinkedIn messages via Voyager API. Supports replies (by thread ID), new conversations (by recipient fsd_profile_id), text-only, and text+attachment.
# Reply (text only)
node scripts/linkedin-send.js --thread "2-XXXX==" --text "Hello"
# Reply with attachment
node scripts/linkedin-send.js --thread "2-XXXX==" --text "Here is my CV" --file /path/to/cv.pdf
# New conversation (text only)
node scripts/linkedin-send.js --recipient "ACoAA123" --text "Hi, nice to connect"
# New conversation with attachment
node scripts/linkedin-send.js --recipient "ACoAA123" --text "Here is my CV" --file /path/to/cv.pdf
# Specify a tab name (default: auto-detects first available tab)
node scripts/linkedin-send.js --thread "2-XXXX==" --text "Hello" --tab linkedin
Flags: --thread <id> (reply, format 2-XXXX==), --recipient <id> (new conversation, format ACoAA...), --text <message> (required), --file <path> (optional attachment), --tab <name> (optional, auto-detects)
Requires: browser open with LinkedIn loaded on a tab
Endpoints: legacy for text-only, dash endpoint for attachments (see browser-automation/apps/linkedin.md for details)
还没有评价。欢迎分享你的使用感受。
无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。
还没有评论。来写第一条吧。
Searches for jobs on LinkedIn, filters by profile Must-haves, applies via Easy Apply, registers each application in DB.
中文简介正在准备中。当前显示原文说明。
Control a dedicated browser via agent-browser (Vercel). Use when automating web apps, scraping authenticated sites, filling forms, or navigating SPAs. Do NOT use for desktop apps or API-only integrations.
中文简介正在准备中。当前显示原文说明。
Periodic routine that runs news, cleans up inbox and applies if there's no recent activity. Designed to run 1-2 times per day.
中文简介正在准备中。当前显示原文说明。
Opens a local web dashboard that visualizes the job application pipeline, funnel stats, recent messages, and target company status. The agent opens it at the end of a round so the user can visually review the pipeline.
中文简介正在准备中。当前显示原文说明。
Safe Postgres CLI. Reads DATABASE_URL from .env, runs SQL, prints JSON. Read-only by default.
中文简介正在准备中。当前显示原文说明。
Autonomous user preference detection, storage and injection. The agent detects preferences from conversation, saves them to Postgres, and loads active ones at the start of every flow.
中文简介正在准备中。当前显示原文说明。