| name | pk-lead-intelligence |
| description | Daily Packaging-division lead enrichment + weekly status doc for Salesforce. Reads PK Leads, researches each one, writes a structured Note to the Lead's Related tab, and publishes a Tuesday status doc to Google Drive. Headless Salesforce UI automation only — no Salesforce REST/Connected-App use. |
pk-lead-intelligence
PK Lead Intelligence is the autumn customer skill that runs the daily
enrichment + weekly status pipeline for the Packaging (PK) division.
It signs into Salesforce as the human owner via the org's standard
Microsoft SSO + TOTP flow, reads PK Leads, researches each one, writes
a structured Note back to the Lead's Related tab, and on Tuesday
mornings publishes a weekly status doc to Google Drive.
The skill drives the Salesforce Lightning UI through Playwright. It
does not use a Salesforce Connected App, the REST API, SOQL, or
Apex. Authentication is the same path a person uses.
This document is the operator handoff. Read it end-to-end before
running the skill.
Status by Phase
The skill ships in phases. Selector verification against HU's live
Lightning landed on 2026-05-21 (issue #563); Phases 3/4 are now
end-to-end against the production org, not stubbed.
| Phase | Status | What is real |
|---|
| 1 — auth + storage | ✅ live | Microsoft SSO + 1Password Service Account; storage-state reuse |
| 2 — enrichment dry-run | ✅ live | Lead fetch, Perplexity + Claude + LinkedIn research, .docx render |
| 3 — reporting validation | ✅ live (2026-05-21) | Validate-only navigation to three operator-owned artifacts: All Sources PK Leads report (00OS700000IzEBlMAN), PK Inbound Web Lead and Activity Tracking - SerenAI dashboard (01ZS7000004KhcnMAC), PK Inbound Web Lead and Opportunity Tracking - SerenAI dashboard (01ZS7000004KhePMAS). Spec contracts unit-tested. |
| 4 — live Note write | ✅ live (2026-05-21) | Per-record Business Unit -> PACKAGING checkbox DOM read (cross-division gate); SerenDB pk_lead_enrichment_log ledger (24h recency); Quill-editor Note-form driver; load-bearing write-then-stamp order; --allow-live × live_mode=true dual gate; weekly doc renderer + Drive share. |
| 5 — cron + slash command | ✅ live (2026-05-21) | scripts/setup_cron.py (daily + weekly local-pull jobs via seren-cron), scripts/run_local_pull_runner.py (claims due ticks, dispatches to agent.py, auto-pauses on publisher 402), scripts/slash/pk_status.py (reads state/weekly_status_runs.jsonl, surfaces latest doc URL or offers on-demand --command weekly run). JSON envelope on --command run and docs/failure_modes.md remain v1 follow-ups. |
Architectural notes (issue #563 closeout)
Three pieces that shipped differently than the original Phase 3/4
spec, all because the operator's Salesforce permission set in HU is
constrained to a regular-user role (no Setup access):
-
Custom Lead fields were not created. PACKAGING__c,
Last_Enrichment_At__c, and Activity_Gap_Days__c from the
original spec do not exist and never will. The cross-division
gate reads HU's existing Business Unit section instead and
admits only records where PACKAGING is checked. Recency moved
to a SerenDB-owned pk_lead_enrichment_log table because
Salesforce does not need to know when the skill last touched a
Lead.
-
Reports + dashboards are operator-owned, not skill-created.
The Lightning Report Builder and Dashboard Builder live inside
Aura-app iframes that cannot be cleanly driven every cron tick.
The three artifacts above were cloned manually by Nathan; the
skill validates each is still reachable on every provision tick
but does not edit them.
-
Per-record detail-page read for the division gate. The original
spec assumed the All Sources PK Leads report would surface
PACKAGING__c as a column the cron could read from the list
view. With the field gone, the cron now navigates to each record's
detail page and reads the Business Unit -> PACKAGING checkbox.
One extra page-load per Lead per cycle is acceptable at the
skill's volume.
How to tell what state you are in
--command run --dry-run — works end-to-end against a real org login. Reuses a valid Playwright storage session before reading Salesforce credentials, reads candidates from the pinned All Sources PK Leads report, verifies Business Unit -> PACKAGING on the Lead detail page, produces a .docx for the first matching Lead, and exits.
--command run --batch --dry-run — reads candidates from the pinned All Sources PK Leads report, keeps only records whose Lead detail page has Business Unit -> PACKAGING checked, and renders local .docx Notes without Salesforce writes.
--command run --allow-live — requires inputs.live_mode: true AND inputs.serendb_connection_uri set in config.json. Live runs populate is_packaging from the Lead detail page before enrichment, then enforce the 24h recency gate via the SerenDB ledger, then drive the Note form on PK Leads only.
--command provision --allow-live — navigates to each of the three pinned artifact URLs and confirms they load under the operator's session. Does not edit them.
--command weekly — renders the weekly Google Doc and uploads + shares it.
When to Use
- daily PK lead enrichment cron (runs on weekdays at 06:00 in the
org's local timezone)
- weekly PK status doc generation (runs Tuesday mornings)
- ad-hoc backfill on a fresh window of recent PK Leads
- manual
/pk-status slash command to read this week's status doc
- diagnostic re-runs after a Salesforce / Microsoft Authenticator
outage to clear backlog without duplicating Notes
Do not use this skill to write into divisions other than Packaging.
Mis-routed enrichments are a P0 defect — the PK / PL / MD / NW split
exists in the source data and the skill respects it.
Setup
Prerequisites
- Python 3.11+ on the always-on host that will run cron.
- Read access to the org's Salesforce production tenant for the
named human owner.
- Access to a 1Password Service Account that can read the SF login
item.
- A Seren account with
SEREN_API_KEY and enough SerenBucks to cover
daily Perplexity + Claude calls (~$0.25/run today). See
API Key Setup below — there are three supported
paths depending on where you run the skill.
- A Google Drive folder ID where the weekly status doc should land,
and an email to share each new doc with.
API Key Setup
The skill calls five Seren publishers over HTTPS: perplexity (Lead
research), seren-models (Claude hypothesis), google-drive (weekly
doc upload + share), seren-cron (daily / weekly schedules), and
seren-db (SerenDB connection URI for the pk_lead_enrichment_log
ledger). Every one of those calls is authenticated by SEREN_API_KEY
(or API_KEY, which Seren Desktop injects). Pick the path that
matches where you are running the skill:
-
Seren Desktop. No setup. The desktop runtime injects API_KEY
and the agent can probe mcp__seren-mcp__list_projects to confirm
auth. Skip the rest of this section.
-
Claude Cowork (the desktop Claude app). Cowork installs custom
MCP connectors through its GUI, not a shell command:
- Open Claude Desktop.
- Go to Settings > Connectors.
- Click Add Custom Connector.
- Paste the URL:
https://mcp.serendb.com/mcp
- Trigger any MCP call (e.g. ask Claude to list Seren projects);
the hosted MCP completes OAuth in your browser on first use.
The hosted MCP exposes every publisher this skill calls. No .env
entry is needed — Cowork carries the session for you.
-
Claude Code (the CLI). Install the same hosted MCP via the
claude command:
claude mcp add --scope user --transport http seren \
https://mcp.serendb.com/mcp
Trigger any MCP call to complete OAuth. If you also want a
SEREN_API_KEY in .env for cron runs, paste the key the MCP
issues:
SEREN_API_KEY=<the-key-the-mcp-handed-back>
-
No setup — just run the skill (cold-start auto-register). If
none of the above is configured, the skill registers a fresh Seren
agent account on its first publisher call, writes
SEREN_API_KEY=<key> to <skill-root>/.env, and continues. A
one-line warning is emitted on stderr so you can see what
happened. This is the path Claude Cowork users hit when they
activate the skill before setting up the connector — Jill never
sees an error.
-
Locked-down host with no outbound to /auth/agent either (CI,
air-gapped cron). Register the account from a machine that does
have outbound, then paste the key into <skill-root>/.env:
curl -sS -X POST https://api.serendb.com/auth/agent \
-H 'Content-Type: application/json' \
-d '{"name":"pk-lead-intelligence"}'
Copy .data.agent.api_key from the response — it is shown only
once — into <skill-root>/.env as SEREN_API_KEY=....
Do not create a duplicate account if a key already exists. The
/auth/agent endpoint always issues a fresh $0-balance key; a
second account does not inherit your team's SerenBucks. The
cold-start auto-register in path 4 only fires when neither API_KEY
nor SEREN_API_KEY is set in the environment AND no
<skill-root>/.env exists with a key — so an existing team key is
never overwritten. Always check <skill-root>/.env (and probe
mcp__seren-mcp__list_projects when MCP is available) before
invoking the registration curl manually.
Reference: https://docs.serendb.com/skills.md.
Salesforce credentials
The skill needs your Salesforce username, password, and the TOTP seed
backing your MFA rolling code. Pick whichever path fits your setup —
the skill tries the env-var path first and falls back to 1Password
Business if env vars are unset.
Path A: env vars in .env (consumer-friendly, no 1Password Business needed)
Works on consumer 1Password (Personal/Families), no 1Password at all,
or any other secrets store. The skill reads three env vars and
computes the rolling 6-digit code locally via pyotp. Issue #795.
- Paste the three values into
<skill-root>/.env:
SF_USERNAME=jill@your-company.com
SF_PASSWORD=<your-salesforce-password>
SF_TOTP_SECRET=<base32-seed-from-MFA-setup>
- Get the
SF_TOTP_SECRET value one of two ways:
- From an existing 1Password (Personal works fine): open your
Salesforce login item → Edit → click the one-time-password field
→ reveal/copy the underlying secret (the long base32 string,
not the 6-digit code that rotates).
- From a fresh MFA setup: in Salesforce, Settings → Identity
Verification → Add Verification Method → Mobile App. The setup
screen shows a QR code AND a text panel labeled "Can't scan?
Use this secret key" — copy that string.
- Confirm:
pip install -r requirements.txt (pulls pyotp), then
python -c "from scripts.auth.op_service_account import read_salesforce_credentials as r; print(r(vault='', item='').totp_code)"
should print a 6-digit code that matches your authenticator app.
Set all three or none. Setting two of three is rejected with an
explicit error — half-set env can otherwise mis-route the SSO
driver if it silently falls through to op.
Path B: 1Password Business Service Account (the original path)
Use this if your org already runs 1Password Business or Teams and
wants secrets centralized. Requires a Business/Teams plan — the
consumer Personal and Families plans do not expose Service Accounts.
- In 1Password admin, create a vault named
PK Salesforce Skill
and add one login item named PK Salesforce. The item must carry
username, password, and a TOTP field.
- Create a Service Account scoped to read-only access on that
vault. Generate its token.
- On the host that will run the cron, install the
op CLI:
brew install --cask 1password-cli (or the Linux package).
- Verify:
op --version returns 2.x.
- Set
OP_SERVICE_ACCOUNT_TOKEN in .env (see Configuration).
- Make sure
SF_USERNAME, SF_PASSWORD, and SF_TOTP_SECRET are
not set — the env-var path runs first and would shadow this
one.
- Sanity-check from the shell:
op vault list must list PK Salesforce Skill.
op item get "PK Salesforce" --vault "PK Salesforce Skill" --otp must print a rolling 6-digit code.
Never paste the Service Account token into chat or commit it. The
.gitignore blocks .env, but the token is the most sensitive
credential in this skill — protect it like a production password.
Install
Seren Desktop installs this skill at ~/.config/seren/skills/pk-lead-intelligence/ and provisions the Python venv, Chromium, and dependencies for you. You do not run any shell commands.
On your first /pk-lead-intelligence invocation, the skill detects that no config.json exists, auto-provisions everything it can on its own (SerenDB project + database, Google Drive folder, Seren API key), and emits a JSON envelope listing the answers only you can give. Your Seren chat agent (Claude in this chat) reads that envelope and asks you for each value in turn — right here in chat — then persists each answer back into your local config via python scripts/agent.py --command bootstrap --set <key>=<value>. Seren Desktop itself does not prompt for, transport, or store any of these answers; the chat AI is the only thing that asks you, and your values stay on your machine. See §First-Run Bootstrap below for the envelope contract.
When all answers are saved, the next invocation runs a dry-run end-to-end and renders the Note for review.
Maintainers running the skill from a clone of seren-skills can still set it up manually:
cd autumn/pk-lead-intelligence
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
playwright install chromium
The chat-driven bootstrap above is the supported path for everyone else; .env and config.json are written by the bootstrap command, never copied by hand.
First-Run Bootstrap (chat-AI contract)
pk-lead-intelligence does not assume a Seren Desktop setup wizard exists. The skill itself owns first-run detection, the chat AI owns the conversation, and there is no third party in between.
On any invocation where ~/.config/seren/pk-lead-intelligence/config.json is missing or incomplete, scripts/agent.py runs an implicit bootstrap pass:
- Stages
config.example.json → config.json and .env.example → .env in ~/.config/seren/pk-lead-intelligence/ (idempotent — existing files are never overwritten).
- Auto-resolves silently:
SEREN_API_KEY via resolve_api_key().
serendb_connection_uri via bootstrap_serendb(project_name='pk-lead-intelligence', database_name='pk_lead_enrichment').
google_drive_folder_id via google_drive.create_folder('PK Lead Intelligence — Weekly Reports').
- Emits a single JSON line on stdout listing operator-only fields it still needs:
{
"bootstrap": "needs_input",
"skill": "pk-lead-intelligence",
"auto_resolved": ["SEREN_API_KEY", "serendb_connection_uri", "google_drive_folder_id"],
"missing": [
{"key": "salesforce_org_url", "scope": "config", "prompt": "What URL do you use to log in to Salesforce?", "secret": false},
{"key": "salesforce_owner_email","scope": "config", "prompt": "What's your Salesforce SSO email?", "secret": false},
{"key": "nathan_share_email", "scope": "config", "prompt": "Who should receive the weekly status doc?", "secret": false},
{"key": "SF_USERNAME", "scope": "env", "prompt": "Microsoft / SSO username (often the same as your SSO email).", "secret": false},
{"key": "SF_PASSWORD", "scope": "env", "prompt": "Microsoft / SSO password.", "secret": true},
{"key": "SF_TOTP_SECRET", "scope": "env", "prompt": "Authenticator TOTP secret — see Path A walkthrough above.", "secret": true}
],
"persist_command": "python scripts/agent.py --command bootstrap --set <key>=<value>"
}
Chat-AI responsibilities (Claude in the user's Seren chat):
- Read the envelope. Do not surface it to the user verbatim.
- For each entry in
missing, ask the user the prompt in the chat, one at a time, in order. Mask the user's response when secret: true.
- Persist each answer immediately by invoking
persist_command with that single key, e.g. python scripts/agent.py --command bootstrap --set salesforce_org_url=https://example.lightning.force.com. The bootstrap subcommand is idempotent and routes config keys to config.json and env keys to .env.
- After every write, re-run
scripts/agent.py (no flags). When the envelope flips to {"bootstrap": "ready"}, the same invocation continues into --command run --dry-run and surfaces the rendered .docx for review.
- Never write
inputs.live_mode = true during bootstrap — it is force-clamped to false until the user has reviewed dry-run output and explicitly approves.
Out of contract for Seren Desktop. The Desktop runtime does not see, prompt for, transport, or store the values in missing. It only delivers the skill bundle and the API_KEY. If a Desktop setup wizard ever ships, it will be additive — this envelope contract is the supported UX today.
Configuration
.env
Every variable in .env.example is required. The file is
gitignored.
| Variable | Source | Notes |
|---|
SEREN_API_KEY | Seren Desktop or https://docs.serendb.com/skills.md | Used for Perplexity + Claude + Google Drive publisher calls. |
OP_SERVICE_ACCOUNT_TOKEN | 1Password admin | Read-only SA scoped to the SF vault. |
OP_VAULT | Hardcoded to PK Salesforce Skill | Rename only if the vault is renamed. |
OP_ITEM | Hardcoded to PK Salesforce | Rename only if the item is renamed. |
config.json
The example in config.example.json ships with all required keys.
The fields the operator typically edits:
| Field | Default | Notes |
|---|
inputs.salesforce_org_url | https://<org>.lightning.force.com | Replace with the live org URL. |
inputs.salesforce_owner_email | empty | The Microsoft / SSO email the skill signs in as. |
inputs.live_mode | false | Defense-in-depth. Salesforce writes also require --allow-live on the CLI. |
inputs.monthly_close_target_usd | 500000 | Drives the rolling-forecast pacing math. Adjust quarterly. |
inputs.google_drive_folder_id | empty | Where the weekly doc lands. |
inputs.nathan_share_email | empty | Who the weekly doc is auto-shared with. |
schedule.daily_cron | 0 6 * * 1-5 | Weekdays at 06:00 in schedule.timezone. |
schedule.weekly_cron | 0 7 * * 2 | Tuesday at 07:00. |
schedule.timezone | America/New_York | Operator-local. |
limits.max_leads_per_daily_run | 50 | Hard cap. The skill will not enrich more than this even if the report returns more rows. |
perplexity.* / claude.* | sensible defaults | Override only if a model is deprecated. |
The populated config.json is gitignored — only config.example.json
is committed.
Run
Dry-run (no Salesforce writes)
The default and the only path that should run while the operator is
still validating Note quality.
python scripts/agent.py --command run --dry-run
Outputs a local .docx of the rendered Note for the first matching
Lead and exits. Re-run on a fresh Lead until the Note format passes
operator review.
Live single-shot
After live_mode=true is set in config.json and the operator
has reviewed at least five dry-run Notes:
python scripts/agent.py --command run --allow-live
Both live_mode=true and --allow-live are required. Either alone
refuses to write. This is intentional — see Pre-Run Checklist below.
ContentNote throttle (issue #783)
Salesforce enforces a ~90-second window between sequential
ContentNote writes on the Lightning UI session. The skill pauses
between Notes in --batch --allow-live by default; without the
pause, batches silently drop Notes after the 2nd–3rd Lead. Override
via inputs.pause_between_notes_seconds in config.json (default
90) or the --pause-between-notes <seconds> CLI flag. Skipped /
failed / non-PK Leads do not trigger a pause — they did not consume
a throttle slot. Set to 0 only when running through a Salesforce
Connected App + REST path (not the default UI flow).
Weekly status doc
The weekly doc is normally cron-driven, but a manual run is fine:
python scripts/agent.py --command weekly
It refuses to run unless live_mode=true because the doc references
real, written Notes.
Slash command
Inside a Seren Desktop chat:
/pk-status
Returns the doc URL and the executive summary for the most recent
weekly run. If no doc exists this week yet, offers to trigger an
on-demand --command weekly run.
Schedule
The skill runs on seren-cron with these defaults (timezone:
America/New_York):
| Job | Cron | What it does |
|---|
pk-lead-intelligence-daily | 0 6 * * 1-5 | Enrich up to max_leads_per_daily_run PK Leads; write Notes if live. |
pk-lead-intelligence-weekly | 0 7 * * 2 | Generate the weekly status doc and share it. |
The schedule lives in seren-cron; a long-lived local pull runner on
the always-on host claims due ticks. To register and start the
runner:
python scripts/setup_cron.py create --config config.json
python scripts/run_local_pull_runner.py --config config.json
Leave the runner process alive on the always-on host. Closing it is
equivalent to pausing the cron.
To pause or resume:
python scripts/setup_cron.py list
python scripts/setup_cron.py pause --job-id <id>
python scripts/setup_cron.py resume --job-id <id>
Pre-Run Checklist (before flipping live_mode=true)
Run through this every time the operator enables live writes — at
initial cutover and after any extended outage:
op vault list succeeds. The Service Account is reachable.
op item get "PK Salesforce" --vault "PK Salesforce Skill" --otp
returns a fresh 6-digit code.
python scripts/agent.py --command run --dry-run succeeds end-to-
end on one Lead and produces a clean .docx.
- Operator and the human owner have reviewed at least five dry-run
Notes and explicitly signed off on the format.
config.json has inputs.live_mode = true, monthly_close_target_usd
matches the current target, and google_drive_folder_id +
nathan_share_email are non-empty.
- SerenBucks balance covers at least one full daily run plus a
weekly run with margin.
- Salesforce Lightning is reachable from the host (no VPN /
firewall block).
- No other operator is mid-edit on the All Sources PK Leads report
or PK Lead Dashboard. The skill drives those artifacts and will
stomp concurrent edits.
If any item fails, do not flip live_mode=true. Fail closed.
Emergency Stop
If a bad Note format ships to production or the skill starts writing
into the wrong division, stop it immediately:
python scripts/setup_cron.py list
python scripts/setup_cron.py pause --job-id <daily-job-id>
python scripts/setup_cron.py pause --job-id <weekly-job-id>
These three steps independently block writes. Any one is enough to
stop new Notes; the recommendation is all three so a tired operator
cannot accidentally undo the stop.
The skill does not auto-delete or auto-rollback Notes that have
already been written. If a bad Note batch shipped, the operator
manually cleans it up in Salesforce and the local pull runner stays
paused until the renderer is fixed and re-validated against
dry-runs.
Privacy & Compliance
This skill handles customer-confidential CRM data. Read this before
operating it.
- Credentials never leave the host. The Service Account token,
Salesforce login, and TOTP are read from 1Password at runtime,
held in memory for the duration of one run, and discarded when
the process exits. They are never written to logs, screenshots,
or committed files.
- No screenshots. The skill takes Playwright screenshots only
for selector debugging during development. The
.gitignore
blocks *.png to keep dev screenshots out of git. Never paste
Salesforce screenshots into chat or share them outside the org —
they always carry PII.
- LinkedIn discovery and optional profile scraping. The
discovery module uses search-engine queries to find candidate
LinkedIn URLs and surfaces the URL plus a match-confidence
score. The profile scraper (issue #781) is available behind the
inputs.linkedin_scraping_enabled config flag (default false);
when enabled, the skill reads the operator-authenticated
profile page using the same Playwright context as Salesforce
and stores the structured fields it extracts (current title,
tenure, prior roles, education, skills, recent activity). The
scraper never enumerates connections in bulk, never bypasses
LinkedIn's session model, and soft-fails to None on the
signed-out gate so the Note falls through to the existing
not-surfaced markers.
- Salesforce is the source of truth. When LinkedIn and
Salesforce disagree on a person's title or role, the Note
surfaces the discrepancy as an observation. It does not silently
overwrite the Salesforce record with the LinkedIn value.
- SerenDB persistence. The skill writes a durable
enriched_leads row per enrichment so the same Lead is not
re-researched on every run. Rows are kept indefinitely — the
audit trail of what the skill told the human owner about each
Lead is part of the deliverable. Never delete rows from the
ledger; update in place if a Lead is re-enriched.
- Division boundary. PK is one division of four (PK / PL / MD
/ NW). The skill only acts on records flagged
PACKAGING = True.
A mis-routed enrichment that lands a Note on a non-PK Lead is a
P0 defect, not a cosmetic bug.
- Note content is human-reviewable. Every Note carries the
enrichment timestamp, the research sources used, and a short
hypothesis section. If a downstream reader cannot reconstruct
why the Note says what it says, the renderer has regressed —
fail closed and fix the renderer instead of writing the Note.
- Tax / legal interpretations are out of scope. The skill
surfaces information; it does not classify deals, render tax
positions, or make compliance judgments. Those decisions stay
with the human owner.
If any of these guarantees is unclear or violated by a code path,
treat it as a release-blocking bug. Privacy and division-boundary
defects are not "ship and patch later" — they are stop-the-line.
Failure Modes
The companion docs/failure_modes.md (ships in phase 5) covers each
of the recurring operational failures and the recovery procedure for
each — Salesforce session expiry, Microsoft Authenticator drift,
Playwright selector rotation, Perplexity / Claude rate limits,
Google Drive sharing failures, SerenBucks depletion, etc. Until that
file lands, escalate non-trivial failures to the implementing
engineer; do not freelance recovery steps that touch live Salesforce
records.
Disclaimer
This skill drives a real production Salesforce org as a real human
user. It can write Notes that other people read. Bugs in the
renderer or division boundary produce real-world cleanup work in a
customer-confidential system. Honor the dry-run gate. Honor the
live_mode + --allow-live double gate. Pause the cron at the
first sign of trouble. The skill is software tooling and not
financial, legal, or tax advice.
Taariq Lewis, SerenAI, Paloma, and Volume at https://serendb.com
Email: hello@serendb.com