Go-To-Market SDK + CLI for account research, enrichment, CRM sync, and outreach. Layered architecture: thin CLI → workflow orchestration → single-SDK adapters. Deployable as Modal serverless functions; consumable as an editable Python package or git submodule.
- Package name:
gtm(entrypoint script:gtm = cli.main:run) - Python:
>=3.13,<3.14 - Package manager:
uvonly (never barepip) - License: MIT
gtm-sdk/
├── cli/ # Thin command surface (Typer). Parses flags, preflight, calls src/.
├── src/ # Workflow orchestration. Chains libs/ adapters. Modal endpoints register here.
├── libs/ # Single-SDK adapters. One folder per external service. NO cross-lib imports.
├── data-gen/ # Reusable data generation/enrichment pipelines (independent, composable).
├── webhooks/ # Standalone Modal webhook handlers (e.g. GCP raw/ETL exporters).
├── api/
│ ├── specs/ # External API OpenAPI specs (e.g. caldotcom)
│ └── samples/ # Sample payloads (rb2b, caldotcom)
├── tests/ # pytest, importlib mode. Mirrors src/, libs/, cli/.
├── tmp/ # Gitignored scratch. ALL temporary files go here.
├── worktrees/ # Gitignored. All git worktrees under this dir.
├── deploy.py # Modal deploy entrypoint (must stay at root — avoids `attio` pkg shadowing).
├── pyproject.toml
└── uv.locklibs/<x>/wraps one external SDK or API. Idiomatic Python types/functions. Nolibs/<x>may import fromlibs/<y>.src/chains adapters into workflows. Modal@app.function/@modal.fastapi_endpointdecorators live here.cli/is Typer-only: parse args → preflight → call intosrc/→ render. No business logic.data-gen/products are independent; do not depend on each other.
Anti-patterns: orchestration inside libs/; business logic inside cli/; cross-lib imports.
| Adapter | Purpose |
|---|---|
apollo |
People + organization enrichment (via gtm-apollo) |
attio |
CRM: companies, people, notes, attributes, values |
browserbase |
Headless browser sessions |
caldotcom |
Cal.com bookings/events |
dlt |
dltHub filesystem destinations (GCP + local), DestinationType |
fathom |
Meeting recordings + transcripts |
filesystem |
File utilities for pipeline I/O |
gmail |
Gmail URL decoding |
granola |
Local Granola export reader |
harvest |
LinkedIn lead/profile data via Harvest |
linkedin |
LinkedIn member-data helpers |
octolens |
Mention monitoring |
openai |
Lead extraction via OpenAI |
parallel |
Parallel.ai web search / extract / findall |
parsers |
Generic parsers |
perplexity |
Perplexity API |
rb2b |
RB2B website visitor identification |
resend |
System/transactional email |
telemetry.py |
OTEL tracer init + emit_cli_event |
src/app.py— ModalAppdefinition, image build, secret bindings (apollo,attio,parallel), endpoint module registration. Edit here when adding new Modal endpoints.src/modal_app.py—MODAL_APPname (env-overridable viaMODAL_APP, defaultelvis-ai-v2).src/api_keys.py— API key resolution.src/enrichment.py— Enrichment workflow.src/accounts/—accounts,research,people,batch,tasks,models.src/attio/—companies,people,notes,deployment_parity,http_responses.src/apollo/—organizations,people.src/parallel/—extract,findall,search.src/{caldotcom,fathom,granola,octolens,rb2b}/— workflow modules per integration.
Run via uv run gtm <group> <command> (or uv run python -m cli.main).
gtm
├── hello, version
├── accounts GTM workflow commands
│ ├── research Non-mutating research
│ ├── enrich Non-mutating enrichment
│ ├── find-people Non-mutating people discovery
│ ├── map-account-hierarchy Non-mutating hierarchy mapping
│ ├── batch-add-people Batch add (preview/apply)
│ └── batch-add-companies Batch add (preview/apply)
├── apollo
│ ├── people People enrichment + search
│ └── organizations Org enrichment + search
├── attio
│ ├── people Manage people records
│ ├── companies Manage company records
│ └── notes Manage notes
├── enrichment
│ └── enrich Enrich records from LinkedIn (Harvest)
├── gmail
│ └── url Gmail URL decoding
├── granola
│ └── export Local Granola export
└── parallel
├── extract Extract content from URLs
├── findall Discover entities (FindAll)
└── search Search the webCLI helpers: cli/json_encoder.py, cli/json_validation.py. CLI emits OTEL events (cli.usage_error on exit code 2).
git submodule add git@github.com:elviskahoro/gtm.git gtm-sdkIn the parent pyproject.toml:
[tool.uv.sources]
gtm = { path = "gtm-sdk", editable = true }Then uv sync. All cli, src, libs packages become importable.
git clone --recurse-submodules git@github.com:elviskahoro/gtm.git
cd gtm
uv syncuv sync # install/lock deps
uv run gtm --help # CLI help
uv run gtm <group> <cmd> --help # subcommand help
uv run pytest # full test suite (importlib mode)
uv run pytest tests/cli # subset
trunk check --all # lint + typecheck (ruff, etc.)uv run modal deploy deploy.pydeploy.pylives at the repo root on purpose — moving it undersrc/causessrc/attio/to shadow the pipattiopackage.- App name resolves from
MODAL_APPenv var; falls back toelvis-ai-v2. - Image is debian_slim + Python 3.13 with a pinned subset of deps and local
libs/+src/mounted viaadd_local_python_source. - Secrets used:
apollo,attio,parallel(ModalSecret.from_name). - Free tier cap: 8 web endpoints total. Parallel endpoints are gated behind a plan upgrade.
- Endpoint modules are imported in
src/app.pyfor decorator registration — when adding a new endpoint module, add the import there.
Build env vars baked into the image: AI_BUILD_GIT_SHA, AI_DEPLOYED_AT.
Standalone Modal apps under webhooks/:
export_to_gcp_raw.py— raw payload sink to GCS (bucketdlthub-devx-test-bucket, secretdevx-gcp-202605111323).export_to_gcp_etl.py— ETL variant.
Deploy each independently with modal deploy webhooks/<file>.py.
OTEL traces and logs emitted from libs/telemetry.py. Two export modes:
Collector fan-out (default). The collector Modal app name is hard-coded in
libs/telemetry.py (DEFAULT_COLLECTOR_APP = "otel-collector"), so collector fan-out is the
default mode with no per-environment wiring — override the app name with TELEMETRY_COLLECTOR_APP
(function name fan_out, override with TELEMETRY_COLLECTOR_FUNCTION). The app exports to a single
middle layer: a custom OTEL exporter serializes each batch to OTLP protobuf and
fire-and-forget .spawn()s the collector Modal function (src/otel_collector.py) — pure
Modal RPC, no public endpoint. That function hands the bytes to a real OpenTelemetry
Collector running as a localhost sidecar in the same (always-warm, min_containers=1)
container; the sidecar fans out to all configured providers — Dash0, HyperDX, Logfire —
with real batching, retry_on_failure, and a sending queue. Provider credentials live on
the collector only, not on each app container. The sidecar's OTLP receiver binds to
127.0.0.1, so it is never reachable from outside the container. (Queue is in-memory; a
container recycle can lose an unflushed batch — fine for non-load-bearing telemetry.)
Deploy the collector on its own (its own Modal app, not a web endpoint):
infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env=dev \
-- uv run modal deploy src/otel_collector.pyThe collector reads provider creds from its own secret: DASH0_AUTH_TOKEN +
DASH0_OTLP_ENDPOINT (optional DASH0_DATASET, default default), HYPERDX_API_KEY
(optional HYPERDX_OTLP_ENDPOINT), LOGFIRE_WRITE_TOKEN (optional LOGFIRE_OTLP_ENDPOINT).
Each unconfigured provider is silently skipped.
Direct single-sink (fallback). Opt out of the collector by setting
TELEMETRY_COLLECTOR_APP=""; telemetry then goes to one OTLP sink directly, activated by
HYPERDX_API_KEY / HYPERDX_OTLP_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT (custom collector).
This path has no Logfire exporter — Logfire is reachable only via the collector. Useful for
local dev.
If neither is configured → no-op (telemetry is never load-bearing). CLI calls
init_tracer() at startup and emits cli.usage_error events on Typer exit code 2.
- Temp files:
tmp/only. Never alongside source. - Branches:
agent/<slug>for agent-created branches. Neverclaude/*. - Worktrees: under
worktrees/<branch-name>. Never use.git/modules/*paths as user-facing worktrees. - Commits: never add AI co-author trailers (
Co-Authored-By: Claude/Oz/...). Human authors only. - Documentation: live in code (docstrings, README per major module, CHANGELOG entries). Do not create summary/investigation
.mdfiles. - Path anchoring in scripts: anchor file I/O on
Path(__file__).resolve().parent, never the CWD —uv run path/to/script.pydoes not chdir.
pytestwith--import-mode=importlib(already inpyproject.toml).- Layout mirrors source:
tests/cli/,tests/libs/,tests/src/,tests/integration/. - Integration smoke:
tests/integration/test_gtm_remote_smoke.py. S101(assert) is allowed intests/**(ruff per-file ignore).
When adding functionality:
- External SDK call? → New file in
libs/<service>/. Wrap one SDK only. No cross-lib imports. - Multi-step flow / Modal endpoint? →
src/<service>/. Register module import insrc/app.pyif it defines endpoints. - User-facing command? →
cli/<group>/. Typer subapp. Call intosrc/. Wire intocli/main.pyviaapp.add_typer(...). - Standalone data product? →
data-gen/<product>/. Self-contained. - Webhook handler? →
webhooks/<name>.py. Independent Modal app.
See AGENTS.md (symlinked from CLAUDE.md) for the authoritative version of these rules.