An agentic revenue crew for B2B sales teams. It researches accounts, scores leads against your ICP, drafts outreach and logs every touch. Your reps approve, edit or reject from Slack. Agents do the work. Humans keep the judgment calls.
Built on Agno with FastAPI and Postgres. Five agents, one team, two workflows.
- A founder or first sales hire doing outbound alone. The crew handles research, scoring and drafts; you approve from Slack between calls.
- A small SDR team on HubSpot and Instantly. Every touch is logged, deals are deduped across runs, and the manager reads a digest instead of asking around.
- Evaluating agent systems. Mock mode runs the whole pipeline against Postgres with zero credentials, so you can inspect exactly what an agent team would do to your CRM before you connect one.
git clone https://github.com/sanityvsvanity/revcrew && cd revcrew
python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python start.pystart.py asks one question: demo or live.
Demo, zero keys. The full pipeline runs against local Postgres in about two minutes: real approval gate, real outbox, real database state, canned agent outputs. No credentials, no .env editing. The demo walks a Tier A lead from intake to booked call in seven beats and exits 0.
Live, your stack. The wizard collects Slack, HubSpot, Instantly and model credentials one integration at a time, checks each against the real API as you enter it, and writes your .env. Everything is optional and independent: connect one integration, test it, come back for the next. Slack alone already gives you the full approval experience with CRM and outreach safely mocked.
Prefer commands over prompts? docker compose up -d && .venv/bin/python -m demo.run_demo is the demo, and Setup, step by step is live mode by hand.
Working with a coding agent? Point it at this repo and tell it to follow AGENTS.md. It covers the spin-up, what credentials to ask you for at each stage, and how to verify every integration before touching the next.
Worth being precise about, because most agent demos are smoke and mirrors.
Real in every demo run:
- The approval gate. A row lands in the
approvalstable, the Block Kit message goes through the ChatPort, and the push step is unreachable until the row flips to approved. Kill the process mid-run and the approval survives. - The event outbox. Replies enter as
eventsrows and get dispatched with capped retries and a dead-letter state. - Every adapter call. The mock HubSpot, Instantly and Slack adapters write real rows to Postgres that you can inspect with psql.
Canned in demo mode: the agent outputs. They live in demo/data/canned.json, validate against the schemas in app/schemas.py, and exist so the demo is deterministic and free. Set DEMO_MODE=false with a model provider configured (Ollama or an Anthropic key) and the real agents run instead.
The demo closes by reading the state back out of Postgres:
State written to Postgres by this run:
mock_crm_objects company: 1, contact: 1, deal: 1, note: 2, task: 1
mock_campaigns 1 (paused)
approvals approved: 1
events processed: 1
mock_messages 2
- A new lead arrives with intent signals
- Researcher produces an account brief
- Qualifier scores it against the ICP rubric in
app/icp.yaml - Outreach writer drafts a three step sequence
- The approval gate opens in Slack. Approve pushes a paused campaign to Instantly and contact, company, deal and note to HubSpot
- A reply comes back through the webhook, gets triaged, and the rep gets an alert with a drafted response
- Ask the copilot to prep you for the call
The rubric lives in app/icp.yaml: target segments, weighted criteria, tier cutoffs and hard disqualifiers. The qualifier's scoring instructions are built from this file at startup, so what you write there is exactly what the agent scores against. Edit it in place, or point ICP_PATH at your own copy to keep your rubric out of the repo, then restart.
Weights must sum to 100 and every criterion needs a description. A broken rubric stops the server with an error that says which line to fix. Check an edit without restarting:
.venv/bin/python -c "from app.icp import load_icp; load_icp(); print('rubric ok')"The B tier cutoff comes from ICP_SCORE_THRESHOLD, the same number that gates the pipeline: leads scoring below it never reach the outreach writer. One caveat: demo mode agent outputs are canned, so a rubric edit shows up in live runs, not in the demo walkthrough.
The researcher holds four tools. crm_history checks whether the prospect already exists in your CRM and pulls recent activity, because a warm contact needs a different email than a cold one. web_search runs multiple angled queries (overview, funding, hiring, tech stack), each returning titled results with URLs. fetch_page pulls the readable text of pages worth reading closely, usually the homepage and careers page. lookup_company_enrichment serves the demo's pre-loaded data.
Two provider tiers, resolved the same way as the models: with no keys, search is DuckDuckGo and fetches are direct HTTP, free and fine for evaluation. Set FIRECRAWL_API_KEY and both search and scraping switch to Firecrawl, which survives rate limits and JavaScript-heavy sites properly. A Firecrawl failure falls back to the free tier for that call and logs it. RESEARCH_PROVIDER pins a tier explicitly.
The evidence rules are the point: sources in a brief may only contain URLs that actually came out of a tool, anything research could not establish lands in a gaps list instead of being guessed, and failed searches say so instead of failing silently. A guessed detail becomes personalization in a real email, which is why the prompt treats empty fields as correct and invented ones as harmful.
| Component | Model tier | Job |
|---|---|---|
| researcher | main | Account brief from CRM history, web search and page fetches, outputs AccountBrief |
| qualifier | fast | Scores against app/icp.yaml, no tools, outputs LeadScore |
| outreach_writer | main | Drafts sequences, outputs SequenceDraft, holds no send tools |
| crm_scribe | fast | Sole holder of CRM write tools |
| copilot + gtm_desk | main | Slack-facing team that fields questions and call prep |
| lead_pipeline | workflow | research, qualify, gate on score, draft, approval, push |
| reply_triage | workflow | classify, log to CRM, alert the rep |
Agents ask app/models.py for a role, never a model id, so the whole crew moves between providers with env vars. On Ollama the main tier is qwen3:14b and the fast tier qwen3:4b by default; on Anthropic they are Sonnet and Haiku. Prompts live as versioned files in app/prompts/.
The integrations are ports and adapters. CRMPort, OutreachPort and ChatPort are protocols in app/integrations/ports.py; DEMO_MODE decides whether the registry hands out mocks or the live HubSpot, Instantly and Slack adapters. One partial-live rule: in demo mode with a SLACK_BOT_TOKEN set, chat goes live while CRM and outreach stay mocked, which is the right setup for demos in a real workspace.
More detail in docs/architecture.md.
- Nothing is pushed anywhere until a human clicks Approve. The push step reads its inputs from the approved row, so there is no path around the gate.
- Every CRM write goes through a guard: validated, capped per run, deduplicated, and logged to an audit table you can query. The copilot's answer to "what did you do this week" comes from that table, not from memory.
- A second qualified signal for a company with an open deal becomes a note on that deal, not a duplicate deal.
- Suggested replies are drafts. The system never sends a reply on its own.
- Campaigns are always created paused. Activation refuses to run when
ENV=devunless forced.
.venv/bin/python start.py automates the install and the credential collection below, including a live check of each key as you enter it. This section is the same path by hand, plus the parts a wizard cannot do for you (creating the Slack app, the HubSpot private app, the Instantly webhook).
Each integration goes live independently. Do them in order, test after each one, stop wherever you like: Slack alone is already a working demo, and mock mode needs nothing at all.
Requirements: Python 3.12, Docker.
git clone https://github.com/sanityvsvanity/revcrew && cd revcrew
docker compose up -d
python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m demo.run_demoPostgres runs on port 5541 and the schema applies itself at startup, including upgrades on existing databases.
./scripts/dev.shThis copies .env.example to .env if you don't have one and serves on port 8000. Check it's up:
curl http://localhost:8000/healthSlack needs to reach your server. For a local trial, open a tunnel and note the domain it prints:
cloudflared tunnel --url http://localhost:8000- Go to api.slack.com/apps → Create New App → From a manifest. Paste
slack/manifest.yaml, replacingPLACEHOLDERin the three URLs with your tunnel or deployment domain. The server must be running: Slack verifies the events URL when you save. - Install the app to your workspace. Copy the Bot User OAuth Token (
xoxb-...) intoSLACK_BOT_TOKEN, and the Signing Secret from Basic Information intoSLACK_SIGNING_SECRET. - Create a channel,
/invite @RevCrewinto it, and copy the channel ID (bottom of the channel details pane) intoSLACK_CHANNEL_ID. - Restart the server, then run
/demo new-leadin the channel. A card with Approve, Edit, Reject and View emails should appear. WithDEMO_MODE=truechat is live while CRM and outreach stay mocked, so you can click everything without touching real systems. - Optional: put the Slack user IDs allowed to approve in
APPROVER_SLACK_IDS, comma-separated. Empty means anyone in the channel.
Use a developer test account first, not your production portal.
- In the test account: Settings → Integrations → Private Apps → Create a private app.
- Scopes:
crm.objects.contacts.readand.write, same forcompaniesanddeals. - Copy the token into
HUBSPOT_PRIVATE_APP_TOKEN. - Verify:
curl -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" "https://api.hubapi.com/crm/v3/objects/contacts?limit=1"The adapter dedupes before create (contacts by email, companies by domain) and prefixes every note with RevCrew: so you can always tell what the system wrote. Set HUBSPOT_DEFAULT_OWNER_ID if you want tasks assigned to someone by default.
- Copy an API v2 key from your workspace settings into
INSTANTLY_API_KEY. - Configure a webhook pointed at
https://your-domain/webhooks/instantly, sending a shared secret in theX-RevCrew-Secretheader. Put the same value inINSTANTLY_WEBHOOK_SECRET. - Campaigns arrive paused, always. Review them in the Instantly UI before activating anything.
One setting decides the provider. MODEL_PROVIDER=auto (the default) uses Ollama whenever it is configured, Anthropic otherwise:
- ollama.cloud: set
OLLAMA_API_KEYand leaveOLLAMA_HOSTempty - Local Ollama: point
OLLAMA_HOSTat your server, e.g.http://localhost:11434 - Anthropic: set
ANTHROPIC_API_KEYand no Ollama variables
Heavy roles (research, writing, copilot) use OLLAMA_MODEL_MAIN (default qwen3:14b) or Sonnet; light roles (scoring, triage, CRM entry) use OLLAMA_MODEL_FAST (default qwen3:4b) or Haiku. Set MODEL_PROVIDER=anthropic or ollama to pin a provider regardless of what else is configured.
When Ollama is primary and an Anthropic key is also set, reply triage retries once on Anthropic if the local model fails or returns unusable output, and says so in the logs. Small local models occasionally miss structured output; the retry is there so a flaky classification never drops a prospect reply.
The app is one uvicorn process plus Postgres. Any host that runs both works; Railway, Render and Fly all do it with a managed Postgres attached.
- Provision Postgres 17 and set
DATABASE_URL. The schema applies itself on first start. - Deploy the repo with
pip install -r requirements.txtas the build step and this as the start command:
uvicorn main:app --host 0.0.0.0 --port $PORT- Set the environment variables from your
.env. SetOS_SECURITY_KEYto a long random string: it becomes the bearer token protecting the AgentOS API endpoints. - Update the three Slack URLs (events, actions, commands) and the Instantly webhook to the deployment domain.
ENV=prod, which makes missing webhook secrets a hard rejectDEMO_MODE=false- Send a test lead through
/api/leads: it should land in HubSpot with a paused campaign in Instantly - Simulate a reply at
/webhooks/instantly: it should produce a triage alert and a CRM task - Confirm the digest arrives at the hour set in
DIGEST_HOUR/DIGEST_TZ - Check
write_auditand the Instantly UI: no campaign has ever been activated by the system
Approval TTLs, the digest schedule, write caps, retention and model settings all have sane defaults, documented in .env.example. The condensed version of this section lives in docs/integrations.md.
- Leads arrive through
/api/leads, or/demo new-leadwhile you're evaluating. - Each qualified lead becomes a Slack card: who they are, the score, three subject lines, the deal, and exactly what will be written. View emails shows the full bodies. Edit opens a pre-filled modal and updates the card in place. Reject asks why, and the reasons roll up in the digest.
- Approve pushes contact, company, deal and a note to HubSpot, then a paused campaign to Instantly. You activate campaigns in the Instantly UI. If a push fails partway, the thread gets a Retry button, and retries skip whatever already succeeded.
- Replies come back through the webhook, get triaged, and land as an alert with a drafted response and a follow-up task. Nothing sends without you.
- Pending approvals get one reminder after 24 hours and expire after 72. Both are configurable.
- Each morning a digest posts what was approved, rejected and written, plus anything that needs attention: failed pushes, dead-letter events.
- Mention the bot for call prep or a straight answer about pipeline state.
/demo new-leadwalks the next seed lead up to the approval gate/demo replyfeeds a canned reply through the outbox and triage/demo resetclears mock state- Mention the bot to talk to the crew (needs a configured model provider)
Webhook hygiene: Slack requests are verified with the v0 HMAC signature, stale timestamps outside a five minute window are rejected, and Slack retries are deduped. Instantly webhooks verify a shared secret. A missing secret rejects everywhere except a pure-mock dev demo.
.venv/bin/python -m pytest98 tests: schemas, discovery, the setup wizard's env handling, the research toolkit (provider fallback, URL hygiene, evidence formatting), the ICP rubric (validation and that the qualifier prompt really reflects the file), signature rules, outbox retry and dead-letter, webhook auth, guarded writes, the approval flow end to end (edit in place, retry after a failed push, deal dedup, reject reasons), and a golden path test that asserts the demo leaves exactly the state it claims. DB-backed tests skip when Postgres is down.
RevCrew ships with an OpenTelemetry bridge for Agno Viz, a 3D topology visualizer for agent systems. Set TRACING_ENABLED=true with the AGNO_VIZ_* variables and watch the crew think in real time. The bridge package is not in requirements.txt: install it separately from the Agno-viz repo, otherwise the flag is a no-op.
Gagan Dasari. I run GTMpro, an agentic GTM engineering practice in Melbourne. I build agent teams for revenue work: research, qualification, outreach and conversation, wired into the stack you already run.