Turn public intent signals into warm conversations.
Apollo finds their contact info. OutreachGraph finds where they are actually active, what they care about right now, and tells you the least intrusive useful way to start the conversation — then refuses to send anything the platform, the prospect, or your own rate limits say it should not.
The deterministic core, the API, the PWA, the outreach composer and the execution layer are built and tested. A prospect can go from a GitHub handle to a drafted, policy-checked card in the approval queue, be enrolled on a multi-step cadence, be contacted by email or by a public Bluesky reply, and have the reply move their score — today.
The rest of the §20 agent suite is not built. See
docs/prd-implementation-map.md for exactly
what exists.
The Content Studio adds editable LinkedIn, newsletter and article drafts in a product's saved voice. Drafts stay in the workspace until a person reviews and copies them out; the public AI-speak checker runs entirely in the browser.
The Content Radar (/content, og radar, /api/v1/content-radar) reads each
product's niche daily for the posts that got the most attention (Hacker News,
Bluesky, dev.to, Mastodon, Reddit, and followed competitors), names each hook
and why it worked, and turns the best into posts per network, a short-video
storyboard and a branded image card. See docs/content-radar.md.
curl -fsSL https://outreachgraph.com/install.sh | shThat installs og (@profullstack/outreachgraph
on npm) with bun if you have it, otherwise npm (Node >= 20), into ~/.local when
the global prefix is not writable. npm install -g @profullstack/outreachgraph
works too. Then set OUTREACHGRAPH_API_URL=https://outreachgraph.com and
OUTREACHGRAPH_API_TOKEN to a key from Settings, and run og today.
bun install
bun run db:migrate # applies migrations to DATABASE_URL (default: a ./local.db SQLite file)
bun test # 1231 tests
bun run check # format, typecheck, testRun the API and the PWA:
bun run --filter '@outreachgraph/api' dev # :8080
bun run --filter '@outreachgraph/web' dev # :3000og and the MCP server are clients of /api/v1 and nothing else. That is not
a convenience: the policy engine runs server-side, so a surface that could
reach past it would be a surface where the gates are optional.
export OUTREACHGRAPH_API_URL=http://localhost:8080
export OUTREACHGRAPH_API_TOKEN=$API_TOKEN
export OUTREACHGRAPH_WORKSPACE_ID=wsp_... # only with the service token; an og_live_ key needs neither
export OUTREACHGRAPH_ORGANIZATION_ID=org_...
bun apps/cli/src/index.ts today # the approval queue
bun apps/cli/src/index.ts prospects # ranked prospects
bun apps/mcp/src/index.ts # MCP over stdioThe MCP server's claim is that an agent driving it cannot be talked into breaking a platform's terms. That holds because the refusal is a pure function on the server rather than an instruction in a prompt: every mutation is an HTTP call that re-runs the deterministic policy engine, the engine fails closed, and the process holds no database handle — so there is no faster path a persuasive caller can be pointed at.
No API keys are needed. The pipeline runs end to end on a deterministic
fixture provider, so a fresh checkout works with an empty .env.
The openprofile job assembles one OpenProfile.md
per person from their public profiles, through @profullstack/openprofile. It
is private until somebody switches it on, and it can be corrected from every
surface: what the owner writes wins section by section, the rest is still
generated, and a rewrite by the job never touches the corrections.
og profile per_... # the file, as this workspace sees it
og profile edit per_... [--file profile.md] # correct it ($EDITOR when no --file)
og profile publish per_... --public|--private # list it for directories, or stopThe same three over HTTP: GET /api/v1/people/{id}/openprofile.md,
PUT /api/v1/people/{id}/openprofile (the whole file as text/markdown, or a
JSON overlay of identity, headline, sections, public, handle), and
POST /api/v1/people/{id}/openprofile/publish {public}. MCP:
get_openprofile, update_openprofile, publish_openprofile.
A public profile is served to anybody, minus email, phone and any Contact
section, and listed at GET /api/v1/openprofiles?since=&limit=&cursor= for a
directory such as nichedb.dev to pull. A suppressed person is never public.
The person may correct their own profile without a session here: an OpenAccess
bearer with the openprofile:edit scope is honoured when its principal is
provably them, by an email this deployment verified or by the OpenProfile.md
they publish.
Outreach leaves over SMTP as cha0spunk@profullstack.com or
knox@profullstack.com only, connected on Settings and verified against the
real server before anything is stored. Every send goes through
@profullstack/email-queue:
BullMQ on Redis schedules it, Postgres audits it, and its gates run first:
- Never Resend, never another account. Resend is for platform account mail only (verification, digests, alerts). A workspace with no usable mailbox sends nothing; a mailbox outside the two above cannot be used at all.
- Stop, never fail over. A provider block (SMTP auth failure, any 5.7.x
policy refusal, persistent throttling) pauses that mailbox's queue. The
message is refused with
SENDER_PAUSED, the mailbox is stopped on the Mailboxes page, the prospect's sequence stops and is surfaced as a blocker in the activity feed, and nothing moves to the other mailbox. Resuming is a manual step after the provider issue is fixed (resumeEmailQueue()plus resuming the mailbox). - One key, one send. Each step is keyed
outreach:<workspace>:<prospect>:<recommendation>; a replay returns the earlier receipt, and an uncertain outcome is never resent. - Compliance. Cold outreach is
marketing: one-clickList-Unsubscribe(https, RFC 8058,POST /u/:tokenwithList-Unsubscribe=One-Click, no login) andOUTREACH_POSTAL_ADDRESSin the text and HTML bodies, appended when the draft lacks it. Unsubscribes, IMAP stop-requests and IMAP bounces are written to the queue's suppression, which blocks every mailbox. - Budgets. At most
OUTREACH_MAX_PER_RECIPIENT_DOMAIN_PER_DAY(default 3) cold messages per recipient domain per mailbox per 24 hours. - Fail closed. Without
EMAIL_QUEUE_REDIS_URL,EMAIL_QUEUE_ENCRYPTION_KEYand a Postgres database, startup logs the problem and outreach email is off; nothing is ever sent directly.
Storing a customer's mail password needs a key to encrypt it with —
SECRET_ENCRYPTION_KEY, documented in .env.example. Without it, connecting a
mailbox is refused rather than stored in the clear.
apps/
api/ Hono service on /api/v1
web/ Next.js 16 mobile-first PWA
server/ the single entrypoint: API, PWA and the background loop
packages/
ai/ the only package that talks to a model: composer + quality gates
pipeline/ the discovery-to-queue chain and its background jobs
email/ the sending boundary: Resend for account mail, SMTP for outreach
domain/ canonical types — depends on nothing
db/ database client (Postgres in production, a SQLite file locally) and migration runner
policy/ the deterministic policy engine
recommend/ next-best-action engine
identity/ cross-network identity resolution
signals/ signal decay
scoring/ ICP fit, intent, reachability, relationship, opportunity
providers/ vendor boundary and enrichment waterfall
contracts/ request/response schemas shared by API and web
migrations/ forward-only .sql for SQLite, applied in filename order
migrations-pg/ the same migrations in Postgres, same file names (production)
docker/ one Dockerfile per deployable service
The pipeline lives in a package rather than an app because both the API (adding a prospect on demand) and the background loop run the same chain — an app importing another app's source would make the dependency direction a lie.
One GitHub handle goes all the way to a card in the approval queue:
enrich → resolve identities → collect signals → score → recommend
packages/pipeline/src/pipeline.ts runs it, and POST /api/v1/prospects is
how a person starts it from the UI. GitHub first because it is free, its
profiles carry links the person published themselves — twitter_username,
blog, company — and developer tooling is the launch wedge. A real profile
typically yields three linked identities before any paid provider is touched.
Each stage persists before the next runs, so a crash resumes rather than restarting, and a half-enriched prospect is still inspectable.
Prospects, approval cards and the daily digest show a photo when a public source
supplies one. Crawls retain JSON-LD Person.image portraits and team images whose
alt text names the person, including lazy-loaded images and relative URLs. GitHub
enrichment keeps the public account's avatar. Photos are stored with the source
page in field provenance; existing photos are kept, and a later crawl or enrichment
can fill an empty photo even after image search previously missed.
These paths use the pages and public API responses already being read. Gravatar
and social profile intake remain available, and VALUESERP_API_KEY optionally
enables the bounded image-search fallback for LinkedIn and company team pages.
Nothing signs into LinkedIn or bypasses a blocked page. Missing or unavailable
photos show initials. Previously imported people gain these photos on their next
crawl or enrichment; this change does not run a production backfill.
The policy engine is arithmetic, not judgement. Every outbound action
passes through packages/policy. It is a pure function with no model in the
loop, it fails closed on anything the capability matrix does not describe, and
each gate may only tighten a decision — so gate ordering can never accidentally
re-permit something. LinkedIn automation is not "discouraged", it is gated:
it runs only through the member's own session, which exists only after the
workspace owner explicitly accepts LinkedIn's terms risk
(og connect linkedin --accept-linkedin-risk), and even then every invitation,
visit, follow, message and comment is paced, capped per kind, and approved by a
human unless a campaign opts into trusted automation. Without that session,
every LinkedIn action is a hand-off.
Policy is re-checked when you approve, not when the card was made. A
recommendation stores the decision it was generated under, but
POST /recommendations/:id/approve runs the engine again against current
state. Suppression, a flipped feature flag, a spent rate limit, or a dropped
identity confidence all block an approval that would have been fine yesterday,
and the response names the gate that stopped it.
A draft that fails its checks is withheld, not shown with a warning. The composer only ever sees stored evidence, stored facts and your own offering — there is no path by which it learns something it may not cite. Its output then runs deterministic gates: every specific assertion must appear in that evidence, or the draft is rejected and rewritten once naming the exact invented fragments. Still failing, nothing is shown. The card keeps the prospect, the evidence and the recommended action, and you write the message. A bad draft next to a caveat is still a bad draft someone might approve.
Evidence combines with noisy-OR, so weak signals never reach certainty.
Identity resolution and intent scoring both use 1 - Π(1 - eᵢ). Two 0.5
observations give 0.75, not 1.0. "Same name, same city" — the classic
false-merge trap — cannot merge two people no matter how many demographic
fields agree, and a pile of vague chatter never outranks one fresh explicit
question.
DELETE /api/v1/people/:id removes the profile, identities, signals, scores,
recommendations, and field provenance — and leaves a suppression tombstone
keyed on the platform account and hashed email. The tombstone is the point: it
survives the deletion so a later provider lookup cannot silently re-ingest
someone who opted out.
Railway, one service per Dockerfile, deploying from this repository.
The root railway.json configures the default service as the API. For each
additional service, open its settings and set the config file path — Railway
only reads a subdirectory config if you point it there:
| Service | Config file path |
|---|---|
| api | /railway.json |
| web | /apps/web/railway.json |
| worker | /apps/worker/railway.json |
Leave Root Directory unset. These Dockerfiles expect the repository root as
their build context; scoping a service to apps/web breaks every COPY and
the workspace install.
Without a config file Railway falls back to railpack auto-detection, which cannot find a start command in a Bun workspace and fails the build — that symptom means the service is not pointed at its config.
Migrations run at boot before anything serves (one replica), or as an explicit
release step with RUN_MIGRATIONS=false. Set up separate databases, provider
keys, and secrets per environment; production customer data is never copied
into staging.
Production runs on Postgres: DATABASE_URL=postgres://..., and the app refuses
to start on anything else there. The client is
@profullstack/libsql-pg, which
keeps the @libsql/client surface the code was written against and rewrites
the remaining SQLite idioms per statement. Locally and in tests a SQLite file
works too (DATABASE_URL=file:./local.db, the default when unset), so a new
migration is written twice — migrations/NNNN_name.sql and
migrations-pg/NNNN_name.sql — and SQL in the app must run on both dialects
(a test checks the two directories carry the same names). TEST_DATABASE_URL
pointed at a Postgres server makes bun test run the database-bound suites
there, one database per test cloned from a migrated template; CI does this.
The data moved off Turso on 2026-09-25 (npx libsql-pg copy); TURSO_*
settings are retired and a leftover libsql:// URL is refused at startup.
See CLAUDE.md. The short version: TypeScript strict, ESM,
kebab-case, colocated tests, forward-only migrations, and a set of
non-negotiables that come from the PRD rather than from taste.
GET /api/v1/public/directory is the one keyless read: what the crawler learned
from pages that were already public, about things that are public by nature.
Companies and sites by their domain, and people only when they publish their own
profile (an OpenProfile.md they serve themselves, or a profile and a home page
that point at each other with rel=me). Never an email, a phone, a location, a
score, a signal, a campaign or a workspace. One row shape,
{ id, kind: company|site|person, name, url, description, topics, country, openprofile, updated },
paged by since and an opaque cursor, cacheable for five minutes, sixty
requests a minute per caller. The rule lives in apps/api/src/public-directory.ts
and nowhere else; nichedb.dev reads it into its directory collection.
The agent-facing surface: /api/v1/autogtm/*. Projects (one per product), campaigns with
budgets in dollars per day, a project-level autopilot that splits the ceiling across campaigns
by reply rate, an inbox with need_reply / replied / sent / unsubscribed tabs, replies,
hot leads, named suppress lists for addresses and domains, and an import that makes a campaign
from your own list.
Read /api/v1/public/llms.txt first (the quick guide) and /api/v1/public/openapi.json for
the schema. Both are keyless and rendered from one table in apps/api/src/autogtm-docs.ts, so
a route cannot appear in one and not the other; a test refuses a documented route that does not
exist.
Authenticate with a workspace key: mint one on /settings (or POST /api/v1/api-keys with a
session) and send it as X-API-Key on every request. A key acts with its owner's current
role and cannot mint keys. The service token and its scope headers still work for internal
callers.
A dollar budget becomes max_contacts_per_day = floor(usd / price_per_contact_usd) on the
campaign, which the policy engine enforces at send time like any other cap. Nothing in this
surface can send outside the engine: a reply is a send_email card marked as a follow-up,
approved by the call itself, and refused with 409 when the lead is suppressed or the budget is
spent.