npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@opengtm/outreach

v0.1.0

Published

CLI for creating, reviewing and running multichannel outreach sequences.

Readme

Conveo outreach CLI

A durable two-touch sequence using Unipile for Gmail and LinkedIn. TypeScript, Node 22+, with DBOS background workflows and Railway Postgres for state and analytics, plus an offline file backend. New sequences default to mock operation; no API credentials are needed for the demo.

Install the CLI

Requires Node.js 22 or newer.

npx --yes @opengtm/outreach --help
# Or install once:
npm install -g @opengtm/outreach
outreach --storage file create executive-outreach --leads ./leads.csv --complete-rows-only --json

The CLI includes default message templates; --messages ./messages.json overrides them. No repository checkout or TypeScript runtime is needed. File storage writes to .outreach in your current directory. Add --dry-run to preview an import without saving.

This release uses local files or a configured DATABASE_URL, like the repository CLI. It does not yet provide a hosted API login. Live sending requires your own Unipile configuration; creating a draft does not send messages.

Frontend

npm run web
# Or use offline file storage:
npm run web -- --storage file

Open http://127.0.0.1:3000. Select Use sample leads to create a draft from data/leads-sample_3.csv and examples/messages.json (25 recipients after deduplication). Alternatively, enter a recipient or use Upload CSV. CSV uploads require linkedin_url, email, first_name, last_name, and company on every row, with valid email addresses and LinkedIn profile URLs. Extra columns are accepted; invalid uploads show row errors without creating a sequence. Proceed with complete rows imports only valid, complete rows and records which rows were skipped. The upload view contains only a file picker and Upload button; the filename becomes the sequence name and uploads create test drafts. Then edit its name, message subjects, bodies and day gaps in two channel timelines. Save changes, approve, and run due messages from the same page. Test sequences also offer time skipping and LinkedIn acceptance. Live sending requires the explicit send confirmation. Pause/resume and refresh work with existing background sequences.

Edits apply to the selected recipient's messages and reset approval. Started sequences retain their sent history; Make an editable copy creates a fresh draft. Custom initial offsets and follow-up delays are respected by the manual runner and background worker. Existing sequences keep their original timing unless edited.

Storage selection matches the CLI: Postgres when DATABASE_URL is set, otherwise .outreach. --store, --storage and --port are supported. The frontend binds to loopback only. No frontend build or additional dependencies are needed.

Manual simulation

CRM preparation during sequence creation

Creating a draft through the UI, CLI or upload API automatically upserts Company and Person records and links each recipient to them. Repeated uploads reuse people by email/LinkedIn and companies by normalized domain. Email/LinkedIn conflicts fail the draft instead of merging different people. Personal email domains are excluded; reviewed company-name mappings resolve sample exceptions such as Boots. Unresolved names do not merge companies automatically.

Existing-account checks use persisted CRM account status, initially seeded from the ten customer-carousel references. Known account matches display a prominent warning in the editor and appear in CLI/JSON warnings. Name matches with conflicting domains produce a possible-match warning. These are warnings only: drafts can still be approved. Checks run for each new draft, including copies; existing sequence warnings are snapshots of that check.

Reviewed categories and customer matches are reused from data/companies-enriched.csv. Unrecognized companies are saved with enrichment_status: needs_enrichment; this version does not call an external research/LLM API. Changes to the reviewed file seed new companies; existing CRM enrichment and manual account overrides are preserved. CRM preparation is a short local/database operation before the existing DBOS send workflow, so it does not require a running worker or any API keys.

Postgres commits the draft, CRM records and enrollment links in one transaction. Apply the new schema once before using an existing database:

npm run cli -- db-migrate
npm run cli -- create account-check --leads data/leads-sample_3.csv --json
npm run cli -- crm
npm run cli -- company-account <company-id> --status existing
# Or correct an account classification with --status prospect

For an offline mock, add --storage file --store /tmp/conveo-crm-demo before each command. create --dry-run --json previews matching and warnings without writing CRM records or a draft. File storage keeps CRM data in .crm.json under the selected directory; mock and live records are separate in both backends. File writes are individually atomic, so an interrupted file save can leave reusable CRM records without a draft; Postgres provides the cross-record transaction.

Agent API: GET /api/crm returns companies and people; POST /api/crm/companies/:id/account accepts {"status":"existing"} or {"status":"prospect"}. Existing sequence responses include person_id, company_id, enrichment fields and account warnings. Optional CSV columns company_domain or company_website provide a company identity independently of the contact email. Creation does not send messages.

npm install
npm run cli -- create demo --leads examples/demo-leads.csv --messages examples/messages.json --start-at 2026-09-14T09:00:00Z
npm run cli -- show demo
npm run cli -- approve demo
npm run cli -- run demo
npm run cli -- advance demo --days 3
npm run cli -- run demo
# Email follow-up is sent; LinkedIn follow-up waits for acceptance.
npm run cli -- accept demo --lead all
npm run cli -- run demo
npm run cli -- show demo

The first run simulates a connection invitation and an email. The day-three run simulates a follow-up email. After simulated acceptance, the next run simulates the LinkedIn follow-up. Running again does not resend completed steps. show displays recipient, rendered copy, schedule, status, step UUID and delivery receipt for every step. outbox shows successful sends only.

Use another sequence ID for a fresh demo, or --storage file --store /tmp/my-demo before the subcommand to isolate offline state.

CSV import for agents

The CLI and web upload share CSV validation and complete-row filtering:

# Validate and preview without writing state:
npm run --silent cli -- create sample --leads data/leads-sample_3.csv --strict --dry-run --json
# Proceed with complete rows and save the draft:
npm run --silent cli -- create sample --leads data/leads-sample_3.csv --complete-rows-only --name "Sample outreach" --json

--strict requires valid values for linkedin_url, email, first_name, last_name, and company. --complete-rows-only implies strict validation and skips invalid/incomplete rows before deduplication. The sample has 22 complete rows, resulting in 21 unique recipients. Without either flag, the original behavior remains: keep usable recipients and skip missing channels.

--json emits one JSON object on stdout. Successful imports include ok, saved, importedRecipients, skippedRows, warnings, and the rendered sequence. Validation errors exit 1 and include ok: false, code, and error; INCOMPLETE_ROWS also includes completeRowCount and skippedRows. Row numbers refer to the uploaded CSV, including its header. Use npm run --silent to suppress npm's banner. --dry-run does not access storage or check for an existing sequence ID. Message templates default to examples/messages.json; override with --messages. Importing never approves or sends a sequence.

Use the supplied leads

npm run cli -- create sample --leads data/leads-sample_3.csv --messages examples/messages.json

CSV requires linkedin_url,email,first_name,last_name,company; extra columns are accepted. Import normalizes LinkedIn URLs and email casing, deduplicates on either email or LinkedIn identifier (first record wins), and skips missing/invalid channels with visible warnings. The sample produces 25 contacts from 26 rows. Invalid enrichment JSON is not parsed or used for this messaging-only v1.

Edit examples/messages.json or supply your own JSON before creating a draft. Supported placeholders: {{first_name}}, {{last_name}}, {{company}}. Invitation notes are limited to 200 characters after expansion. Content is deterministic template rendering, not AI-generated personalization. The CLI creates a review queue and requires approve before execution. To change copy, create a new draft and pause the old one; approval and rendered messages are stored together.

Timing and delivery semantics

  • Initial invite and email are due together at --start-at (default: creation time), and execute sequentially in the same runner tick, subject to the step cap. One channel's failure does not stop the other.
  • Each channel's follow-up becomes due 72 elapsed hours after its initial step actually completes. This avoids immediate follow-ups when an initial send is delayed. When both initial steps complete together, both follow-ups are due together.
  • A normal LinkedIn message waits for a confirmed connection; no automatic InMail fallback. At/after day three the runner checks the profile, then checks at most every 12 hours while waiting. Mock accept releases that wait on the next run. Existing connections or pending invitations skip the redundant invite and start the three-day delay from that check.
  • Gmail follow-ups use reply_to with the original email's provider_id. If Unipile returns a null provider ID, a separate follow-up email is sent with a Re: subject; threading cannot be guaranteed in that case.
  • sent means the provider accepted the send, not delivery to the recipient's inbox.
  • Manual mocks use advance. DBOS mocks use wall time with an optional shorter delay; live workflows always wait 72 hours.
  • start durably enrolls a sequence in DBOS and exits. The deployed service owns its schedule. run and watch remain manual/legacy modes and refuse DBOS-managed sequences.

Live Unipile setup

Connect a Gmail account and a LinkedIn account in the Unipile dashboard, following Getting Started. Add these variables to your existing .env (see .env.example):

UNIPILE_DSN=https://apiXXX.unipile.com:XXXXX
UNIPILE_API_KEY=your-access-token
UNIPILE_GMAIL_ACCOUNT_ID=your-connected-google-account-id
UNIPILE_LINKEDIN_ACCOUNT_ID=your-connected-linkedin-account-id

Use your Gmail account ID, not an IMAP or Microsoft account, for the Gmail variable. Account connection/authentication is handled in the Unipile dashboard in this v1. Account IDs are captured in the live draft so changing environment variables later cannot silently change its sender.

With your own verified recipient CSV:

npm run cli -- create pilot --mode live --leads my-verified-leads.csv --messages examples/messages.json
npm run cli -- show pilot
npm run cli -- approve pilot
npm run cli -- run pilot --live
# Or enroll it in the DBOS service (OUTREACH_ALLOW_LIVE=true required on service):
npm run cli -- start pilot --live

Mock sequences cannot be switched to live. Real sending requires a separate live draft and --live. No live messages were sent during development.

Simulation, failure handling and controls

npm run cli -- pause demo
npm run cli -- resume demo
# Copy a step UUID from show:
npm run cli -- mock-failure demo --step STEP_UUID --outcome reject
npm run cli -- run demo
npm run cli -- mock-failure demo --step STEP_UUID --outcome clear
npm run cli -- retry demo --step STEP_UUID
npm run cli -- run demo

mock-failure --outcome uncertain simulates a timeout where delivery is unknown. It persists until cleared. Explicit rejections become failed; uncertain sends become unknown. Neither is retried automatically. Profile lookup failures are safe failures because no send was attempted. A run containing failed/unknown steps exits nonzero.

Before retrying an unknown/interrupted send, check the actual provider (or choose the intended simulated outcome), then record the result:

npm run cli -- resolve demo --step STEP_UUID --outcome not-sent
npm run cli -- retry demo --step STEP_UUID
# If it WAS sent:
npm run cli -- resolve demo --step STEP_UUID --outcome sent --sent-at 2026-09-14T09:00:00Z --email-provider-id ORIGINAL_PROVIDER_ID

The optional email provider ID enables threaded follow-ups after manual recovery. Resolution as sent requires the original send time so the three-day delay remains correct.

Persistence and scope

With DATABASE_URL set, state and analytics live in Postgres. Each snapshot, normalized step update and corresponding execution events commit in one transaction. A database advisory lock prevents concurrent sequence writers. With --storage file (or no database configured), state lives in ignored .outreach/*.json files, updated with atomic rename. Commands take an exclusive workspace lock. Each send is persisted as sending before the API call; after a crash, interrupted sends are held for manual reconciliation, rather than blindly resent. A hard-killed process may leave .outreach/runner.lock: verify the recorded PID has exited before removing it. Do not manually edit state while a worker is active. Separate state directories do not coordinate or deduplicate with one another.

Unipile email sends include a stable Idempotency-Key per step. Unipile retains these keys for up to 24 hours and can lose them during maintenance; exactly-once delivery is not promised. LinkedIn sends use persisted execution state and manual recovery for uncertain results.

This is a small pilot CLI. DBOS serializes send attempts through the database writer lock but does not enforce account-wide quotas, business hours, unsubscribe processing or cross-sequence deduplication. DBOS workflows stop remaining steps after an attributed reply on either channel, including enrollment-only replies. The reply must be processed before the next send check; an already dispatched message cannot be recalled. Unclassified replies are treated conservatively as a stop. Manual run/watch retain the original behavior and require manual pause on reply.

Code and checks

  • src/create-sequence.ts: CSV import, normalization, deduplication and rendered drafts.
  • src/runner.ts: due-step execution, channel dependencies, acceptance gate and delivery state machine.
  • src/provider.ts: interchangeable mock/Unipile adapters. HTTP transport is injectable for contract tests.
  • src/store.ts: durable local state and command lock.
  • src/cli.ts: creation, review, approval, sending, worker and simulation commands.
npm run check
npm test

Tests exercise the actual sample, timing boundaries, partial failure, unknown outcomes, late acceptance, restart/deduplication, file locking, CLI simulation, and HTTP payload contracts without calling Unipile.

API references reviewed

  • Invite users: resolve public profile identifier with GET /api/v1/users/{identifier}, then POST /api/v1/users/invite using the returned provider ID.
  • Send messages: multipart POST /api/v1/chats; creates or finds a one-to-one chat and sends its initial message.
  • Detect accepted invitations: profile relationship checks; removal from pending invites alone is not proof of acceptance.
  • Send email and endpoint schema: multipart email payload, plain-text header, reply threading and idempotency.
  • Outreach sequences: scheduling belongs in the application, subject to provider limits.

Reviewed September 14, 2026. Live delivery requires account-level validation with your connected accounts; contract tests verify request/response handling, not actual provider delivery.

Railway Postgres and analytics

The provisioned project is conveo-take-home, in the Assembly workspace. Postgres and the mock-only outreach-worker run in its production environment. Local credentials are saved only in ignored .env. No Unipile messages are sent by database setup or analytics commands.

--storage auto is the default: it selects Postgres whenever DATABASE_URL is present, and otherwise uses files. Use --storage file explicitly for a completely offline mock. A database connection failure is an error, never a silent fallback to files. The database service has a public TCP proxy for the local CLI; use the private Railway DATABASE_URL for services running inside the project.

npm run cli -- db-migrate
# Existing file sequences are not automatically copied:
npm run cli -- db-import demo
# Future commands use the database when DATABASE_URL is set.
npm run cli -- show demo

The original local sequence is preserved by import. Existing outcomes are imported without resending; historical transitions that were never stored in the file cannot be reconstructed. Do not run both copies of a live sequence.

Tables:

  • sequences: sequence configuration and recoverable snapshot.
  • sequence_enrollments: one record per lead enrolled in a sequence.
  • step_executions: approved copy, account, schedule, status, attempts and correlation IDs.
  • outreach_events: append-only application event history, including observed engagement.
  • webhook_inbox: durable raw callbacks, with attribution outcome and processing attempts.
  • schema_migrations: applied migration checksum. Schema changes require a new migration.
  • step_analytics: SQL view aggregating unique engaged steps rather than callback counts.

View and simulate analytics

npm run cli -- analytics analytics-demo --mode mock
npm run cli -- show analytics-demo
# Copy a sent email step UUID from show. Choose a timestamp after its mock send time.
npm run cli -- record-event --step STEP_UUID --type opened --at 2026-10-01T10:00:00Z --key demo-open
npm run cli -- record-event --step STEP_UUID --type replied --at 2026-10-01T10:01:00Z --key demo-reply
npm run cli -- analytics analytics-demo --mode mock

Supported event types: opened, clicked, read, replied, connection_accepted, positive_reply, meeting_booked. Repeating the same type/step/key is deduplicated. Events default to mock mode and cannot target live steps. The accept simulation records an acceptance and also releases the mock runner; record-event --type replied also wakes DBOS workflows and stops remaining sends. For a mock connection, use accept to update the simulated provider as well as analytics. Live manual entry (--mode live) is limited to positive_reply and meeting_booked labels.

Reports default to --mode live. Counts are all-time within the chosen sequence. Reply and acceptance rates divide by unique sent steps; skipped invitations are excluded. Sequence totals deduplicate leads across channels. Replies mean observed inbound responses, not confirmed human/positive responses: automatic responses are excluded when their headers identify them, but full classification is not implemented. Opens are observed tracking events, not verified attention. Unattributed replies appear only in sequence totals, not in a guessed step's conversion rate. No causal cross-channel attribution is claimed.

Receive Unipile webhooks

npm run cli -- webhooks-serve

The receiver listens on port 3001 (or PORT/--port). Configure Unipile webhooks to POST JSON to https://YOUR_PUBLIC_HOST/webhooks/unipile, with the custom header Unipile-Auth set to UNIPILE_WEBHOOK_SECRET. Register the messaging, email, email_tracking, and users sources as needed. The DBOS service includes this receiver. Register webhooks separately after configuring the service domain; deployment does not auto-register Unipile webhooks.

The HTTP handler authenticates the request, bounds payload size, and commits it before returning 200. A background processor attributes events every three seconds. Unknown mappings are retained for retry (including events arriving before a send result); ambiguous mappings are retained without picking a lead arbitrarily. The inbox is separate from sequence locks, so sends do not block webhook acknowledgment. Use webhooks-process to process/retry once and see outstanding outcomes. Processing writes durable notifications; enrolled DBOS workflows may wake and dispatch eligible steps after acceptance. Replies stop remaining steps.

Supported v1 events: mail_opened, mail_link_clicked, mail_received, message_received, message_read, new_relation. Outbound echoes and known system events are excluded. Email replies use exact stored provider IDs when available; otherwise a unique sender/account match is attributed to the enrollment only. LinkedIn replies use chat and sender identities; replies without an exact chat match are enrollment-only. Callbacks predating their matched send are excluded. Raw callbacks are retained for inspection/backfill. Sender-account scoping and mock isolation apply to every lookup.

Set UNIPILE_TRACKING_ENABLED=true to enable tracking for future email sends. The adapter then escapes the message into HTML, renders URLs as links, and sends tracking_options with the step UUID as label. Default plain-text sends remain untracked. Confirm tracking with your actual connected Gmail account before using open/click rates for decisions.

Database integration tests

npm run check
npm test
npm run test:db

npm test runs offline tests and skips the Postgres integration test unless TEST_DATABASE_URL is set. test:db uses TEST_DATABASE_URL or the configured DATABASE_URL. Analytics tests use a random isolated schema; DBOS tests create a disposable database to isolate SDK extensions. Both clean up their own test resources. No messages are sent. The DBOS integration test starts a real service subprocess, kills it during a wait, restarts it against the same disposable database, and verifies single sends, independent channels, pause/resume, replies and uncertain outcomes. It drops only its disposable database. DBOS tests require a Postgres role with CREATEDB; this isolates SDK extensions as well as tables.

DBOS background sequences

With the existing .env database connection, start a local worker in one terminal:

npm run service

The service automatically applies application migrations and initializes DBOS in a separate dbos schema. It also serves /health and /webhooks/unipile on PORT (default 3001). Set UNIPILE_WEBHOOK_SECRET to a random value of at least 24 characters. It is mock-only unless OUTREACH_ALLOW_LIVE=true.

In another terminal (or using the deployed Railway worker):

npm run cli -- create dbos-demo --leads examples/demo-leads.csv --messages examples/messages.json
npm run cli -- approve dbos-demo
npm run cli -- start dbos-demo --mock-delay-seconds 10
# Simulate acceptance; it can arrive before or after the delay.
npm run cli -- accept dbos-demo --lead all
npm run cli -- show dbos-demo
npm run cli -- workflows dbos-demo
npm run cli -- analytics dbos-demo --mode mock

The invite and initial email send automatically, followed by the eligible follow-ups ten seconds after each initial completion. Omit the delay flag for the real 72-hour timing. Shortened delays are rejected for live sequences. Timing is immutable once started. Use a fresh sequence ID for a new demo. advance, run and watch refuse DBOS-managed sequences. pause, resume, retry, resolve, accept and mock-failure work without restarting the worker. Pausing prevents future sends but does not move deadlines; overdue work becomes eligible on resume.

To simulate a reply, copy a sent step UUID from show and run record-event --step UUID --type replied --mode mock. Remaining unsent steps on both channels become skipped. To test recovery, stop/restart the worker during the delay; completed sends retain their receipts and attempt counts. A process interrupted during an actual provider call leaves an unknown outcome for reconciliation.

Architecture

The CLI writes the approved enrollment and stable per-lead/channel workflow IDs in one Postgres transaction. It does not need to keep running or launch its own DBOS worker. A service outbox dispatcher starts those workflows idempotently. Changes and relevant analytics events write transactional notification rows; delivery to DBOS uses stable idempotency keys. A crash between delivery and acknowledgment safely repeats the notification. Outbox/webhook reconciliation runs each second; follow-up scheduling itself uses durable DBOS.recv deadlines, not the old watch loop.

Each channel runs the versioned outreach-channel-v1 workflow independently. A DBOS step reads current state, checks stop/pause/acceptance conditions, and attempts at most one send using the existing adapter and analytics transaction. Durable waits sleep until the next deadline or an incoming notification. LinkedIn rechecks the actual profile after acceptance notifications; the webhook never bypasses the connection requirement. Without a webhook it falls back to checking every 12 hours (five seconds for mocks).

Workflow metadata lives in the DBOS system schema. outreach_workflows holds stable enrollment IDs; workflow_notifications is the application outbox. Our existing tables remain the reporting source. workflows ID exposes DBOS status; failed/unknown message outcomes remain in show and analytics. Exhausted infrastructure retries may leave a DBOS workflow in ERROR; investigate and resume using DBOS management tooling rather than creating a duplicate enrollment.

Railway deployment

The outreach-worker service is deployed beside Postgres. Its public receiver is https://outreach-worker-production-edd5.up.railway.app/webhooks/unipile; Unipile webhooks are not registered automatically.

The Dockerfile/railway.json configure the Node service:

  • DATABASE_URL: Railway's private Postgres connection reference.
  • UNIPILE_WEBHOOK_SECRET: the receiver's secret, at least 24 characters.
  • OUTREACH_ALLOW_LIVE=false initially. To send live, configure Unipile credentials on the service and explicitly enroll an approved live sequence with start --live.
  • Optional DBOS_SYSTEM_DATABASE_URL: defaults to DATABASE_URL; DBOS_SYSTEM_DATABASE_SCHEMA defaults to dbos.
  • Start command: npm run service; one replica, serverless sleeping disabled, /health healthcheck.

No local volume is needed by the Node service. Database state survives service restarts. A dedicated Postgres advisory lease permits only one active DBOS worker. During rolling deployments the new receiver can accept webhooks while its worker waits for the old worker to release the lease. Loss of the lease connection terminates the process. This is a single-worker pilot design; add Conductor/coordinated recovery before scaling workers.

Application version is deliberately fixed at outreach-v1. Preserve the existing workflow's step order across deployments; for incompatible changes add a new named workflow and keep the old implementation registered until its runs finish, or use DBOS patching. Do not change the application version on each git commit: outstanding runs need compatible code to recover.

Sources: DBOS workflows, durable messaging, upgrading workflows.