- Python 98.3%
- HTML 1.3%
- Shell 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .w3ds | ||
| config | ||
| linkedin_agent | ||
| scripts | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| test_mail | ||
LinkedIn Activity Monitoring Agent
A personal agent that tracks a predefined watchlist of LinkedIn accounts, posts, and topics, and turns new activity into a ranked digest of action points ("engage", "monitor", "reach_out").
No automated LinkedIn session, ever. This tool never logs into LinkedIn. Ingestion has two paths, both of which stay off linkedin.com:
- Email-notification ingestion (automatic) — LinkedIn already emails you when people/topics you follow are active. This tool polls a mailbox over IMAP and parses those emails.
- Manual paste (fallback) — a local web form for anything the notification stream doesn't cover.
One narrow exception: the digest-time relevance gate
(relevance.enabled, on by default — see below) makes an unauthenticated
HTTP GET of each item's linked article URL to decide whether it's on-topic.
No login, no session, no cookies — and if LinkedIn returns a login wall the
fetch is discarded and the gate falls back to the email excerpt. Set
relevance.enabled: false to keep the whole pipeline off linkedin.com.
See linkedin_agent/future_automation/README.md for why a browser-automation
path was deliberately not built, and what would have to be true before it
ever should be.
One-time setup
-
On linkedin.com: follow/enable notifications for the people and topics in your watchlist, subscribe to relevant hashtags, and turn on weekly digest emails.
-
In your mail provider: create a filter that routes LinkedIn's notification emails into a dedicated folder/label. This keeps IMAP polling scoped to that folder instead of your whole inbox.
-
Install dependencies:
pip install -r requirements.txt -
Configure:
cp .env.example .env # fill in OPENAI_API_KEY and EMAIL_IMAP_* cp config/watchlist.yaml.example config/watchlist.yaml # edit to your real watchlist(
config/watchlist.yamlalready exists with a starter watchlist — edit it directly if you'd rather.)For mailbox auth,
.env.exampledocuments twoEMAIL_AUTH_MODEoptions:app_password(default) — a Gmail App Password. Requires 2-Step Verification; unavailable on accounts with Advanced Protection or some Google Workspace policies.oauth2— for accounts where app passwords aren't offered. Requirespip install '.[oauth]'and a one-timeamber oauth-setuprun to mint a refresh token; see the comments in.env.examplefor the full Google Cloud OAuth client setup.
Usage
amber ingest-email # poll the configured mailbox for new LinkedIn notification emails
amber serve # open http://127.0.0.1:8420 to paste activity manually
amber digest # relevance-filter + summarize pending activity into a ranked digest
amber feedback <id> acted|ignored|escalate
amber status # watchlist size, pending/digested/skipped counts — read-only, no network
All commands can be run manually. Scheduling ingest-email and digest is
safe because neither ever logs into LinkedIn — so both are wired to a
scheduler.
Relevance gate
amber digest first runs each pending item through a topic-relevance filter
(relevance: in config/watchlist.yaml). For every item it fetches the best full
text it can get, in order:
- If the email itself carried a "read the full article" link off linkedin.com
(
external_url), fetch that directly — it's the real article, and unlike a LinkedIn post/pulse URL it usually isn't behind a login wall. - Otherwise, fetch the LinkedIn post/pulse URL itself (
source_url). If that page contains the same kind of "read the full article" link (common when the email didn't carry it but the live post does), follow that one hop too and prefer its text over the post's own. - If neither yields an external article, use the post's own fetched text.
then asks the model whether the content substantively concerns one of your
topics. Items judged off-topic are set to digest_status='skipped'
(with a one-line reason stored in skip_reason) and left out of the digest;
they are not re-evaluated on later runs, but stay in data/app.db so you can
audit or reverse a decision. The fetch is fail-open: a network error, a login
wall, or a judge error keeps the item rather than dropping it. amber status
shows the running skipped count. Turn the whole stage off with
relevance.enabled: false.
Scheduled email ingestion
scripts/cron_ingest_email.sh runs amber ingest-email from the project's
venv and appends output to data/logs/ingest_email.log. Install it as an
hourly cron job:
crontab -e
# then add:
0 * * * * /path/to/w3ds_amber/scripts/cron_ingest_email.sh
Scheduled digest
scripts/cron_digest.sh runs amber digest from the project's venv and
appends output to data/logs/digest.log. It publishes through whatever
output.adapter is set in config/watchlist.yaml — set it to email and
fill in output.email.to (recipient) and the SMTP_* vars in .env to have
the digest land in an inbox instead of data/digests/. If there's no pending
activity, amber digest is a no-op — no email gets sent. Install as a daily
cron job, e.g. 10am:
crontab -e
# then add:
0 10 * * * /path/to/w3ds_amber/scripts/cron_digest.sh
Both are per-machine crontab entries, not part of the repo config. serve
and feedback remain manual-trigger only.
Real-time alerts
amber alert handles pending items one by one as soon as they are ingested:
relevance gate (off-topic items become skipped), then the model picks an
engage level (HIGH / MEDIUM / LOW) and summarizes the fetched article, then
the result is sent as a W3DS message to every w3ds.receivers eName:
Engage level: HIGH
[Jane Doe](https://www.linkedin.com/in/jane/) posted
[Post title](https://www.linkedin.com/posts/...)
Topic: <matched watched topic>
Summary: <summary of the fetched full article, ~10% of its length>
The summary line reuses the same unauthenticated article fetch the relevance gate already makes for that item (see below) — no extra request to linkedin.com beyond what that fetch already needed. That fetch prefers an external "read the full article" link, whether it was captured from the email directly or discovered by reading the LinkedIn post/pulse page itself, over the LinkedIn URL's own text — see "Relevance gate" below for the full order. The summary only appears when a fetch succeeded; if the relevance gate is disabled or every fetch attempt failed (login wall, network error), the alert has no summary line — it is never derived from the short email excerpt.
A drafted reply is still computed internally (linkedin_agent/alerts/suggester.py,
AlertSuggestion.suggestion) for reuse by a future feature, but is no longer part of the
alert message — the summary replaced it there.
Every attempt (sent or failed) is stored in the alerts table of
data/app.db (exact message, level, per-receiver outcome), and logged to
data/logs/ingest_email.log when run by cron. Alerted items get
digest_status='alerted' and no longer appear in the daily digest; a failed
send leaves the item pending so the next run retries it. alerts.max_per_run
(default 10) caps a run so an old backlog can't flood the chat. The account name
is a link only for watchlist accounts; topic and post matches have no profile URL.
scripts/cron_ingest_email.sh runs alert after ingest-email, so the
hourly ingest cron job is what makes alerts near-real-time.
Publishing a digest elsewhere
amber digest renders the digest through the adapter named by output.adapter
in the watchlist config:
file(default) — writes a markdown file underdata/digests/.email— renders an HTML + plaintext message and sends it over SMTP tooutput.email.to. SMTP credentials come from.env(SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD, optionalSMTP_STARTTLS/SMTP_FROM); use an app-specific password. Seeconfig/watchlist.yaml.examplefor the config block and.env.examplefor the credentials.cloud— still a stub (output/cloud_adapter.py).
New delivery channels (Telegram, Slack, …) just need another OutputAdapter
subclass wired into output/__init__.py.
Tests
pytest
Unit tests only — no network calls, no real LinkedIn/email/OpenAI access required.