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 3.0.7 with FastAPI and Postgres. Five agents, one team, two workflows. Since v2 the researcher runs on a tiered research stack with a ledger: every fact in an account brief traces to a recorded tool call, every research call is budgeted and priced, and a grounding gate removes anything the model made up before a rep sees it. The design is in docs/research-stack.md.
- 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 research stack then runs for real as well, at the free tier if you add no other keys.
Also real in every mode, and tested against Postgres in CI: the evidence ledger, the grounding gate, the per-account research budget, the domain policy, and the rule that the workflow ends at the approval gate.
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 pipeline agents have one job each and a short prompt to match. The copilot is the exception: it fields whatever a rep types into Slack, and that work has real procedure behind it. skills/ holds that procedure as three playbooks (call prep, objection handling, pipeline review) in the Agent Skills format, which agno loads natively.
Only a skill's name and description sit in the copilot's prompt, about 600 tokens for all three. The body is fetched with a tool call when a request actually matches, so a rep asking "what's the status on Northwind" pays nothing for the call prep playbook, and a rep asking to prep for a call gets the whole thing: the brief format, the question bank, and the rules that stop a guessed funding round reaching a live conversation.
Two constraints make this safe to leave switched on:
- Skills are read-only. The spec allows a
scripts/directory the agent may execute. RevCrew removes that tool, and a skill folder shippingscripts/refuses to load with an error telling you to put the logic inapp/toolkits/instead, where the write guard and the audit trail apply. The copilot reads prospect-authored text; it does not need a way to run code. - Skills cannot loosen the rules. Untrusted-input fencing, the approval gate and never sending on the agent's own initiative live in
app/prompts/copilot.pyand hold on every turn, including the ones where no skill loads. A skill adds procedure; it never gets the last word on what the agent may do.
objection-handling/references/proof-points.md ships as an empty template, and the skill is told not to cite it while the TODO markers remain — fill it with your own customers and competitive positioning, the same way you replace the rubric. SKILLS_PATH points the whole directory somewhere private; SKILLS_ENABLED=false turns it off. skills/README.md covers writing your own.
The dangerous failure of a research agent is a confident brief with nothing behind it. The case that shaped v2 came from an earlier system of mine: a 30-company brief in which 22 companies had no tool evidence and every founder LinkedIn URL was invented, even though the prompt said not to invent sources. So in v2 the rule is enforced in code after the model runs, and the sources are cheap enough that the researcher can afford to look first.
Sources are tiered, cheapest first, and the stack only escalates when a cheaper tier returned nothing or was blocked.
| Tier | Source | Key needed | What it gives |
|---|---|---|---|
| 0 | Greenhouse / Lever / Ashby job boards, Google News RSS, your CRM | none | Open roles by department (the cleanest buying signal there is), recent coverage with the publisher named, prior contact |
| 1 | Serper → Firecrawl search → DuckDuckGo | optional | Search results with real URLs, ~$0.001 a query when metered |
| 2 | Firecrawl v2 → Jina Reader → direct HTTP | optional | Page text with JavaScript rendered and proxies rotated on a block; one page → one schema-validated object for 5 credits |
| 3 | Stagehand v4 on Browserbase | opt-in | A real browser for JavaScript shells and bot walls: extract(schema) instead of a 40 KB blob; single-use sessions, 120 s cap |
Every call, whether it succeeded, returned nothing, was blocked or was refused, is a row in the evidence table with its tier, provider, URL, content, cost and terms class. That table is the whitelist the grounding gate checks against, the 48-hour fetch cache, the meter behind the per-account budget (25 calls, 40 credits, 180 browser seconds and $0.50 by default), and the answer to "where did this fact come from" in one query.
After the researcher returns, app/research/grounding.py removes every URL the ledger does not hold, both from sources and from prose, moves claim lists into gaps when nothing was fetched, and adds nothing. The report goes on the approval card as one line: Evidence: 7 sources verified, 2 unverified links removed, 9 lookups, $0.011.
LinkedIn, social platforms and review sites are denied before any tier runs and are dropped from search results before the model sees them. The browser tier is off unless enabled and is only reached by escalation. robots.txt is read on direct fetches. The compliance detail, with sources, is in docs/research-stack.md.
What one account costs, against list prices checked on 2026-09-08: $0.00 on the keyless path, $0.01 to $0.02 with Firecrawl and Serper, and about $0.02 more when a page needs the browser. The default budget ceiling is ten to twenty times that.
All seven research tools return the same envelope (ok, error, an error class and a do_not_retry flag), and a failure repeated three times with the same arguments inside two minutes is refused before the provider is called again. You can run the research step from a shell without Slack:
.venv/bin/python scripts/research.py canva.com --company Canva| Component | Model tier | Job |
|---|---|---|
| researcher | main | Account brief from the tiered research stack (Tier 0 signals → search → scrape → browser), grounded against the evidence ledger, 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, holds the skills/ playbooks |
| lead_pipeline | workflow | research (inside a budgeted, recorded run), qualify, gate on score, draft, approval. It stops there; the push runs only from a human Approve |
| 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 workflow ends at the gate (
tests/test_pipeline_shape.pyfails if a step is added after it), and the push reads its inputs from the approved row, so there is no path around it (ADR 0001). - 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. The defaults are local Ollama model names. On ollama.cloud the hosted models have their own ids (for example glm-5.2 and glm-5.3-flash), so set OLLAMA_MODEL_MAIN and OLLAMA_MODEL_FAST to two ids from your account. /health?probe=1 reports degraded with the available ids when a configured model is not on the host.
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. A Dockerfile and railway.toml are included; the image carries no browser, because the browser tier runs on Browserbase.
- Provision Postgres 17 and set
DATABASE_URL. The schema applies itself on first start. - Deploy from the Dockerfile, or 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. SetTIMEZONEto yours: every agent sees the date labelled with its weekday in that zone, and the digest fires in it. - 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 - Open
/health?probe=1: every configured integration should readok; anythingdegradedis a dead key - Confirm the digest arrives at the hour set in
DIGEST_HOURinTIMEZONE - 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 pytest168 tests, about two seconds. The v1 suite covers schemas, discovery, the setup wizard, the ICP rubric, skills safety, signatures, outbox retry and dead-letter, webhook auth, guarded writes, the approval flow end to end and the demo golden path. v2 adds the tool contract and breaker, the domain policy, the grounding gate, the Tier 0 parsers against fixtures shaped like the real APIs, the ledger and router against Postgres with fake providers (waterfall, verified-empty, escalation and its policy gate, budget refusal, cache), and one test that runs the real research step with a scripted model. That model is an agno Model subclass that fetches a page and then cites it together with an invented LinkedIn URL; the test asserts that the gate keeps the first, removes the second, and the ledger records both. DB-backed tests skip when Postgres is down.
Evals (evals/, built on agno.eval) are the regression suite for the model's behaviour. There is one case per failure that has already happened: fabricated URLs, deny-list respect, verified-empty as an answer, and prospect-text injection. They are scored by code that reads the run's tool results, because a judge that only sees input and output cannot know what a tool returned. They cost real calls and run on demand:
.venv/bin/python scripts/evals.py --list
.venv/bin/python scripts/evals.py --tag researcherCI (.github/workflows/ci.yml) runs on every push: ruff, the full test suite against a Postgres service container, the zero-key demo end to end, and a boot check that the app starts and /health reports the database. There is no deploy step.
- Health.
/healthis the cheap liveness check Railway polls./health?probe=1makes one live, read-only call per configured integration (model, Slack, HubSpot, Instantly, Firecrawl, Serper, Browserbase) and reports one of four states for each:ok,degraded(reachable but refused, such as a dead key or no credit),down,unconfigured. Results are not cached between requests. - Traces.
TRACING_ENABLED=trueturns on agno's OpenTelemetry exporter into your Postgres (agno_traces,agno_spans), which AgentOS renders as a span tree per run. The Agno Viz bridge is separate: install its package and set theAGNO_VIZ_*pair to see the crew in 3D. - Cost. Research spend is priced per row when it is written and summarised in the daily digest (
Research: 12 accounts, 91 lookups, $0.14). Per-account detail is inresearch_runs. - When something is down, docs/FAILURE_MODES.md lists what breaks, what survives and where it shows.
- Threat model: SECURITY.md. Rules learned from failures: docs/LESSONS.md. Design decisions: docs/adr.
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.