# Using these docs with AI tools Source: https://gtmsdk.com/ai-tools Machine-readable endpoints for this site — llms.txt, Markdown page variants, and the MCP server — plus the contract an agent needs to drive the gtm CLI. This site is built to be consumed by AI agents as well as people. Everything below is served automatically from the hosted docs — nothing to install. ## Machine-readable endpoints | Endpoint | What it returns | | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://elviskahoro.mintlify.app/llms.txt) | An index of every page with its one-line description. Start here to pick pages worth fetching. | | [`/llms-full.txt`](https://elviskahoro.mintlify.app/llms-full.txt) | The entire site as one Markdown document (capped at 100k characters). | | Any page + `.md` | Append `.md` to any page URL for clean Markdown without site chrome — for example [`/quickstart.md`](https://elviskahoro.mintlify.app/quickstart.md). | | `/mcp` | A hosted [MCP](https://modelcontextprotocol.io/) server exposing search and docs-query tools over this site. Point your MCP client at `https://elviskahoro.mintlify.app/mcp`. | The contextual menu on every page (the button next to the page title) offers the same things interactively: copy the page as Markdown, open it in ChatGPT, Claude, or Perplexity, or connect the MCP server to Cursor and VS Code. ## Driving the `gtm` CLI from an agent The four facts an agent needs before running commands: 1. **Success data is JSON on stdout; errors and logs go to stderr.** Parse stdout only. 2. **Mutating commands preview by default.** Nothing writes until you add the explicit flag (`--apply` on batch and company commands; `--modal-sync` governs Attio people mutations). Copy-pasting an example never mutates state. 3. **Provider-backed commands execute on a deployed Modal app**, not locally. They need Modal credentials plus provider keys — see [Secrets and API keys](/secrets). 4. **Exit codes**: `0` success, `1` runtime error (message on stderr, prefixed `Error:`), `2` usage error. The full contract, including the `--json` payload override every command accepts, is on the [CLI contract](/concepts/cli-contract) page. ## Fetching source directly The repo is public: [github.com/elviskahoro/gtm-sdk](https://github.com/elviskahoro/gtm-sdk). Docs pages reference code as repo-relative paths (for example `src/secrets_bootstrap.py`) — resolve them against the repo root or the GitHub UI. # Launch week — gtm-sdk goes public (July 13, 2026) Source: https://gtmsdk.com/changelog/2026-07-13 First public release of gtm-sdk: the gtm CLI, Attio CRM sync, meeting and mention pipelines, web research adapters, Modal deploy, and OpenTelemetry. The first public release of **gtm-sdk** — a layered Go-To-Market toolkit for account research, enrichment, CRM sync, and outreach. ## New features **`gtm` CLI** A Typer-based command surface for account research, enrichment, and CRM operations. Run `gtm --help` to explore the available command groups. See the [quickstart](/quickstart) to install and run your first command. **Account research and enrichment** End-to-end account and people workflows including batch research, contact parsing, and multi-source enrichment. **CRM sync (Attio)** Read and write companies, people, notes, meetings, and mentions in Attio, with idempotent upserts and preflight checks against your workspace schema. **Meeting integrations** Backfill and sync meetings from **Cal.com**, **Fathom**, **Fireflies**, and **Granola** into Attio, with cross-provider meeting IDs so the same meeting reconciles across sources. **Web research** Search and extract company and people signals via **Exa** and **Parallel**, and pull publisher blog content from **Sanity**. **Sales data providers** Lookup and enrichment adapters for **Apollo** (organizations and people) and **Harvest** (leads). **Visitor and mention pipelines** Webhook handlers for **RB2B** website visits and **Octolens** social mentions, with normalization and Attio export. **Outbound** Send transactional email through **Resend** and post to **Slack** channels or threads. **Serverless deployment on Modal** Orchestration functions and webhook handlers deploy to [Modal](https://modal.com/) with a single `deploy.py` entrypoint. **Telemetry** OpenTelemetry collector with drop-in setup guides for [Dash0](/telemetry/dash0-setup) and [Grafana Cloud](/telemetry/grafana-setup). Structured logs, traces, and metrics from every command and webhook. **Secrets via Infisical** Inject credentials for every provider through a single Infisical token — no per-service `.env` juggling. See the [quickstart](/quickstart#inject-secrets-optional) for the run pattern. ## Updates * Documentation site launched with an [introduction](/), [quickstart](/quickstart), and telemetry provider guides. * Continuous integration hardened: GitHub Actions are pinned to specific commit SHAs for reproducible builds. # Stability and reliability polish (July 14, 2026) Source: https://gtmsdk.com/changelog/2026-07-14 Post-launch reliability updates: stricter Attio select-attribute writability checks and a cooldown window on automated dependency updates. A small post-launch pass focused on reliability and supply-chain safety. ## Bug fixes **Stricter Attio attribute writability checks** The [Attio CRM sync](/quickstart) now correctly gates writes on both archival state *and* attribute type when deciding whether a select attribute is writable. Upserts that previously slipped through against non-select attributes are now rejected cleanly. ## Updates **Safer dependency updates** Automated dependency PRs now honor a cooldown window before adopting freshly published package versions, reducing exposure to compromised or yanked releases in the supply chain. **Cleaner telemetry test surface** Internal telemetry test helpers now match the OpenTelemetry base class signatures they extend, keeping instrumentation exports stable across upgrades of the collector. No action required — see the [Dash0](/telemetry/dash0-setup) and [Grafana Cloud](/telemetry/grafana-setup) guides for setup. # gtm accounts Source: https://gtmsdk.com/cli/accounts Command reference for gtm accounts — GTM workflow commands. Contract: - Success data is always JSON on stdout. - Errors are always printed to stderr. - Mutating commands require --apply; default is preview/no-op. Generated from the CLI help text. GTM workflow commands. Contract: * Success data is always JSON on stdout. * Errors are always printed to stderr. * Mutating commands require --apply; default is preview/no-op. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Examples Research an objective, then preview a batch CRM load: ```bash theme={"system"} theme={"system"} uv run gtm accounts research "Find competitors to Acme Corp" ``` ```bash theme={"system"} theme={"system"} uv run gtm accounts find-people "VP of Engineering at Series B startups" ``` ```bash theme={"system"} theme={"system"} # Preview (default): prints the planned writes, mutates nothing. uv run gtm accounts batch-add-people \ --records '[{"email": "jane@acme.dev", "first_name": "Jane", "last_name": "Doe"}]' ``` ```json Output (preview) theme={"system"} theme={"system"} { "mode": "preview", "requested": 1, "created": 0, "skipped": 1, "conflicts": 0, "errors": 0, "results": [{ "status": "would_create", "email": "jane@acme.dev" }], "source": "elvis.gtm.batch_add_people", "applied_at": null } ``` Add `--apply` to the same command to perform the writes. ## Commands ### `gtm accounts research` Run non-mutating research. ```bash theme={"system"} uv run gtm accounts research [OPTIONS] [OBJECTIVE] ``` | Argument | Type | Required | | ----------- | ---- | -------- | | `OBJECTIVE` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm accounts enrich` Run non-mutating enrichment. ```bash theme={"system"} uv run gtm accounts enrich [OPTIONS] [URL] [OBJECTIVE] ``` | Argument | Type | Required | | ----------- | ---- | -------- | | `URL` | text | no | | `OBJECTIVE` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm accounts find-people` Run non-mutating people discovery. ```bash theme={"system"} uv run gtm accounts find-people [OPTIONS] [QUERY] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `QUERY` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm accounts map-account-hierarchy` Run non-mutating account hierarchy mapping. ```bash theme={"system"} uv run gtm accounts map-account-hierarchy [OPTIONS] [ACCOUNT] ``` | Argument | Type | Required | | --------- | ---- | -------- | | `ACCOUNT` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm accounts batch-add-people` Batch add people with explicit preview/apply behavior. ```bash theme={"system"} uv run gtm accounts batch-add-people [OPTIONS] ``` | Option | Type | Default | Description | | ----------------- | ------- | ------- | ---------------------------------------------- | | `--records` | text | `""` | JSON array of person records | | `--apply` | boolean | `false` | Perform writes to Attio | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | Preview by default: without `--apply` this command prints the planned mutation and writes nothing. See the [CLI contract](/concepts/cli-contract). ### `gtm accounts batch-add-companies` Batch add companies with explicit preview/apply behavior. ```bash theme={"system"} uv run gtm accounts batch-add-companies [OPTIONS] ``` | Option | Type | Default | Description | | ----------------- | ------- | ------- | ---------------------------------------------- | | `--records` | text | `""` | JSON array of company records | | `--apply` | boolean | `false` | Perform writes to Attio | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | Preview by default: without `--apply` this command prints the planned mutation and writes nothing. See the [CLI contract](/concepts/cli-contract). # gtm apollo Source: https://gtmsdk.com/cli/apollo Command reference for gtm apollo — Apollo API commands. Generated from the CLI help text. Apollo API commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## `gtm apollo people` People enrichment and search via Apollo. ### `gtm apollo people enrich` Enrich a person's data using Apollo. ```bash theme={"system"} uv run gtm apollo people enrich [OPTIONS] ``` | Option | Type | Default | Description | | ------------------ | ---- | ------- | ----------------------------------------------- | | `--email, -e` | text | — | Email address | | `--name, -n` | text | — | Full name | | `--first-name` | text | — | First name | | `--last-name` | text | — | Last name | | `--domain, -d` | text | — | Company domain | | `--linkedin, -l` | text | — | LinkedIn profile URL | | `--org` | text | — | Organization name | | `--json` | text | — | JSON payload (overrides flags) | | `--apollo-api-key` | text | — | Override the Apollo API key for this invocation | ### `gtm apollo people search` Search for people in Apollo's database. ```bash theme={"system"} uv run gtm apollo people search [OPTIONS] [KEYWORDS] ``` | Argument | Type | Required | | ---------- | ---- | -------- | | `KEYWORDS` | text | no | | Option | Type | Default | Description | | ------------------ | ------- | ------- | ----------------------------------------------- | | `--title, -t` | text | — | Job titles to filter by (repeatable) | | `--seniority, -s` | text | — | Seniority levels (repeatable) | | `--location` | text | — | Person locations (repeatable) | | `--domain, -d` | text | — | Company domains (repeatable) | | `--page, -p` | integer | `1` | Page number | | `--per-page, -n` | integer | `10` | Results per page | | `--json` | text | — | JSON payload (overrides flags) | | `--apollo-api-key` | text | — | Override the Apollo API key for this invocation | ## `gtm apollo organizations` Organization enrichment and search via Apollo. ### `gtm apollo organizations enrich` Enrich an organization's data by domain. ```bash theme={"system"} uv run gtm apollo organizations enrich [OPTIONS] [DOMAIN] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `DOMAIN` | text | no | | Option | Type | Default | Description | | ------------------ | ---- | ------- | ----------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--apollo-api-key` | text | — | Override the Apollo API key for this invocation | ### `gtm apollo organizations search` Search for organizations in Apollo's database. ```bash theme={"system"} uv run gtm apollo organizations search [OPTIONS] [KEYWORDS] ``` | Argument | Type | Required | | ---------- | ---- | -------- | | `KEYWORDS` | text | no | | Option | Type | Default | Description | | ------------------ | ------- | ------- | ------------------------------------------------------------ | | `--location` | text | — | HQ locations (repeatable) | | `--employees` | text | — | Employee count ranges like '1,10' or '501,1000' (repeatable) | | `--page, -p` | integer | `1` | Page number | | `--per-page, -n` | integer | `10` | Results per page | | `--json` | text | — | JSON payload (overrides flags) | | `--apollo-api-key` | text | — | Override the Apollo API key for this invocation | # gtm attio Source: https://gtmsdk.com/cli/attio Command reference for gtm attio — Attio CRM commands. Generated from the CLI help text. Attio CRM commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Examples Search people by any combination of criteria (name, email, domain, phone, company): ```bash theme={"system"} uv run gtm attio people search --email-domain "acme.dev" --limit 10 ``` ```bash theme={"system"} # Browse recent records without a filter. uv run gtm attio people search --sample ``` Create a person. This writes immediately after the default Modal deployment-parity preflight; `--modal-sync` controls that preflight, not whether the write happens: ```bash theme={"system"} uv run gtm attio people add "jane@acme.dev" \ --first-name "Jane" \ --last-name "Doe" \ --company "acme.dev" ``` Update an existing person by email: ```bash theme={"system"} uv run gtm attio people update --email "jane@acme.dev" \ --company "acme.dev" \ --first-name "Jane" ``` Create or update deterministically by email with `upsert`: ```bash theme={"system"} uv run gtm attio people upsert "jane@acme.dev" \ --first-name "Jane" \ --last-name "Doe" ``` Company creation also writes when invoked (it does not use `--apply`): ```bash theme={"system"} uv run gtm attio companies add --domain "acme.dev" --name "Acme Corp" ``` ## `gtm attio people` Manage people records in Attio. ### `gtm attio people add` ```bash theme={"system"} uv run gtm attio people add [OPTIONS] [EMAIL] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `EMAIL` | text | no | | Option | Type | Default | Description | | ------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | | `--add-email` | text | `[]` | Additional email for this person (repeat for multiple). | | `--first-name` | text | — | First name | | `--last-name` | text | — | Last name | | `--phone` | text | — | Phone number | | `--linkedin` | text | — | LinkedIn profile URL | | `--location` | text | — | Primary location | | `--country-code` | text | — | ISO-3166-1 alpha-2 country code paired with --location (e.g. US, IN). Required for the location to be written; see ai-sfp. | | `--company` | text | — | Company domain | | `--notes` | text | — | Intake notes | | `--strict` | boolean | `false` | Fail fast on optional-field mismatches | | `--location-mode` | text | `city` | Location normalization mode: city\|raw | | `--no-connectivity-probe` | boolean | `false` | Skip Modal connectivity preflight | | `--modal-sync` | text | `check` | Modal sync strategy for mutation commands: check\|deploy\|skip | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override Attio API key | ### `gtm attio people search` ```bash theme={"system"} uv run gtm attio people search [OPTIONS] ``` | Option | Type | Default | Description | | ------------------------- | ------- | ------- | ---------------------------------------------------------------------- | | `--name` | text | — | Search by name (fuzzy, case-insensitive) | | `--email` | text | — | Search by exact email address | | `--email-domain` | text | — | Search by email domain (e.g. continue.dev) | | `--phone` | text | — | Search by phone number (partial match) | | `--company` | text | — | Search by company name or domain | | `--sample` | boolean | `false` | Fetch recent records without filtering (overrides all search criteria) | | `--limit` | integer | `25` | Max results to return | | `--no-connectivity-probe` | boolean | `false` | Skip Modal connectivity preflight | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override Attio API key | ### `gtm attio people update` ```bash theme={"system"} uv run gtm attio people update [OPTIONS] ``` | Option | Type | Default | Description | | ------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | | `--email` | text | — | Email to look up person | | `--id` | text | — | Attio record ID | | `--add-email` | text | `[]` | Email to merge onto the person (repeat for multiple). Existing addresses are kept; duplicates are dropped. | | `--replace-emails` | boolean | `false` | Replace stored emails with lookup identity (--email) plus --add-email only (no merge with existing). | | `--first-name` | text | — | First name | | `--last-name` | text | — | Last name | | `--phone` | text | — | Phone number | | `--linkedin` | text | — | LinkedIn profile URL | | `--location` | text | — | Primary location | | `--country-code` | text | — | ISO-3166-1 alpha-2 country code paired with --location (e.g. US, IN). Required for the location to be written; see ai-sfp. | | `--company` | text | — | Company domain | | `--notes` | text | — | Intake notes | | `--strict` | boolean | `false` | Fail fast on optional-field mismatches | | `--location-mode` | text | `city` | Location normalization mode: city\|raw | | `--no-connectivity-probe` | boolean | `false` | Skip Modal connectivity preflight | | `--modal-sync` | text | `check` | Modal sync strategy for mutation commands: check\|deploy\|skip | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override Attio API key | ### `gtm attio people upsert` ```bash theme={"system"} uv run gtm attio people upsert [OPTIONS] [EMAIL] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `EMAIL` | text | no | | Option | Type | Default | Description | | ------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | | `--add-email` | text | `[]` | Extra email to store (repeat for multiple). On update, merged with existing unless --replace-emails. | | `--replace-emails` | boolean | `false` | On update, set emails to identity email plus --add-email only. | | `--first-name` | text | — | First name | | `--last-name` | text | — | Last name | | `--phone` | text | — | Phone number | | `--linkedin` | text | — | LinkedIn profile URL | | `--location` | text | — | Primary location | | `--country-code` | text | — | ISO-3166-1 alpha-2 country code paired with --location (e.g. US, IN). Required for the location to be written; see ai-sfp. | | `--company` | text | — | Company domain | | `--notes` | text | — | Intake notes | | `--strict` | boolean | `false` | Fail fast on ambiguity or optional-field mismatches | | `--location-mode` | text | `city` | Location normalization mode: city\|raw | | `--no-connectivity-probe` | boolean | `false` | Skip Modal connectivity preflight | | `--modal-sync` | text | `check` | Modal sync strategy for mutation commands: check\|deploy\|skip | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override Attio API key | ## `gtm attio companies` Manage company records in Attio. ### `gtm attio companies add` Add a company to Attio. ```bash theme={"system"} uv run gtm attio companies add [OPTIONS] [NAME] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `NAME` | text | no | | Option | Type | Default | Description | | ----------------- | ---- | ------- | ---------------------------------------------- | | `--domain` | text | — | Company domain | | `--description` | text | — | Company description | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | ### `gtm attio companies search` Search for companies in Attio. ```bash theme={"system"} uv run gtm attio companies search [OPTIONS] ``` | Option | Type | Default | Description | | ----------------- | ------- | ------- | ---------------------------------------------- | | `--name` | text | — | Search by company name | | `--domain` | text | — | Search by exact domain | | `--limit` | integer | `25` | Max results to return | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | ### `gtm attio companies update` Update an existing company in Attio. ```bash theme={"system"} uv run gtm attio companies update [OPTIONS] ``` | Option | Type | Default | Description | | ----------------- | ---- | ------- | ---------------------------------------------- | | `--domain` | text | — | Domain to look up company | | `--id` | text | — | Attio record ID | | `--name` | text | — | New company name | | `--description` | text | — | New description | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | ### `gtm attio companies create-attribute-type` Create an attribute on companies. ```bash theme={"system"} uv run gtm attio companies create-attribute-type [OPTIONS] ``` | Option | Type | Default | Description | | --------------------------------------- | ------- | -------- | ---------------------------------------------------------------- | | `--title` | text | — | Display title for the new attribute | | `--api-slug` | text | — | API slug for the new attribute (derived from title when omitted) | | `--type` | text | `select` | Attio attribute type (e.g. select, text, number) | | `--description` | text | `""` | Attribute description shown in Attio | | `--is-multiselect, --no-is-multiselect` | boolean | `true` | Allow multiple values per record | | `--is-required, --no-is-required` | boolean | `false` | Require a value on every record | | `--is-unique, --no-is-unique` | boolean | `false` | Enforce unique values across records | | `--apply` | boolean | `false` | Perform the write to Attio (default is preview) | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | Preview by default: without `--apply` this command prints the planned mutation and writes nothing. See the [CLI contract](/concepts/cli-contract). ## `gtm attio notes` Manage notes in Attio. ### `gtm attio notes add` Add a note to a person or company in Attio. ```bash theme={"system"} uv run gtm attio notes add [OPTIONS] [TITLE] [CONTENT] ``` | Argument | Type | Required | | --------- | ---- | -------- | | `TITLE` | text | no | | `CONTENT` | text | no | | Option | Type | Default | Description | | ----------------- | ---- | ----------- | ---------------------------------------------- | | `--object` | text | `""` | Parent object: 'people' or 'companies' | | `--record-id` | text | — | Parent record ID | | `--email` | text | — | Look up person by email | | `--domain` | text | — | Look up company by domain | | `--format` | text | `plaintext` | Content format: plaintext or markdown | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | ### `gtm attio notes update` Update an existing note in Attio (replaces via delete + create). ```bash theme={"system"} uv run gtm attio notes update [OPTIONS] [NOTE_ID] ``` | Argument | Type | Required | | --------- | ---- | -------- | | `NOTE_ID` | text | no | | Option | Type | Default | Description | | ----------------- | ---- | ----------- | ---------------------------------------------- | | `--title` | text | — | New title | | `--content` | text | — | New content | | `--format` | text | `plaintext` | Content format: plaintext or markdown | | `--json` | text | — | JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | ## `gtm attio enrichment` Backfill missing fields on Attio records. ### `gtm attio enrichment backfill-domains` Backfill missing domains on Attio Companies. ```bash theme={"system"} uv run gtm attio enrichment backfill-domains [OPTIONS] ``` | Option | Type | Default | Description | | ------------------ | ------- | ------- | --------------------------------------------------------------------------------- | | `--ext-tam-filter` | text | — | JSON filter for ext\_tam (e.g., '\{"source":"snowflake\_scored\_accounts\_csv"}') | | `--company-ids` | text | — | Comma-separated Company record IDs | | `--limit` | integer | — | Max Companies to process | | `--sleep-seconds` | float | `0.0` | Sleep between Companies | | `--apply` | boolean | `false` | Actually PATCH (default: preview only) | | `--json` | text | — | Full JSON payload (overrides flags) | | `--attio-api-key` | text | — | Override the Attio API key for this invocation | | `--exa-api-key` | text | — | Override the Exa API key for this invocation | Preview by default: without `--apply` this command prints the planned mutation and writes nothing. See the [CLI contract](/concepts/cli-contract). # gtm enrichment Source: https://gtmsdk.com/cli/enrichment Command reference for gtm enrichment — LinkedIn enrichment commands. Generated from the CLI help text. LinkedIn enrichment commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## `gtm enrichment enrich` Enrich records from LinkedIn via Harvest API. ### `gtm enrichment enrich fetch` Fetch enrichment from Harvest API and write to file (no Attio changes). Example: gtm enrichment enrich fetch \ \--config enrichment\_config.json \ \--records records.json \ \--profile location \ \--output enriched\_output.json ```bash theme={"system"} uv run gtm enrichment enrich fetch [OPTIONS] ``` | Option | Type | Default | Description | | ----------- | ---- | ------- | ----------------------------------------------------------------- | | `--config` | path | — | Path to enrichment\_config.json | | `--records` | path | — | Path to JSON file with records (record\_id, email, linkedin\_url) | | `--profile` | text | — | Enrichment profile ID (e.g., 'location', 'email', 'skills') | | `--output` | path | — | Path to write enriched records JSON | ### `gtm enrichment enrich upsert` Batch upsert enriched records to Attio CRM. Example: gtm enrichment enrich upsert \ \--input enriched\_output.json gtm enrichment enrich upsert \ \--input enriched\_output.json \ \--dry-run ```bash theme={"system"} uv run gtm enrichment enrich upsert [OPTIONS] ``` | Option | Type | Default | Description | | ----------- | ------- | ------- | ---------------------------------------------------- | | `--input` | path | — | Path to enriched records JSON (from 'fetch' command) | | `--dry-run` | boolean | `false` | Preview changes without updating Attio | # gtm exa Source: https://gtmsdk.com/cli/exa Command reference for gtm exa — Search the web via Exa. Generated from the CLI help text. Search the web via Exa. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Commands ### `gtm exa search` Search the web via Exa. ```bash theme={"system"} uv run gtm exa search [OPTIONS] [QUERY] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `QUERY` | text | no | | Option | Type | Default | Description | | ------------------------------- | ------- | ------- | ----------------------------------------------------------------- | | `--type` | text | `auto` | Search type: auto, fast, instant, deep-lite, deep, deep-reasoning | | `--category` | text | — | Search category (e.g. company, people) | | `--num-results` | integer | `10` | Number of results (1-100) | | `--include-domains` | text | — | Comma-separated domains to include | | `--exclude-domains` | text | — | Comma-separated domains to exclude | | `--highlights, --no-highlights` | boolean | `true` | Include highlight snippets in each result | | `--summary, --no-summary` | boolean | `false` | Include an LLM-generated summary per result | | `--output-schema-json` | text | — | JSON schema for structured output | | `--json` | text | — | Full JSON payload (overrides flags) | | `--exa-api-key` | text | — | Override the Exa API key for this invocation | ### `gtm exa find-companies` Find companies by query via Exa. ```bash theme={"system"} uv run gtm exa find-companies [OPTIONS] [QUERY] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `QUERY` | text | no | | Option | Type | Default | Description | | ------------------------------- | ------- | ------- | -------------------------------------------- | | `--num-results` | integer | `5` | Number of results | | `--highlights, --no-highlights` | boolean | `true` | Include highlight snippets in each result | | `--output-schema-json` | text | — | JSON schema for structured output | | `--json` | text | — | Full JSON payload (overrides flags) | | `--exa-api-key` | text | — | Override the Exa API key for this invocation | ### `gtm exa find-people` Find people by query via Exa. ```bash theme={"system"} uv run gtm exa find-people [OPTIONS] [QUERY] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `QUERY` | text | no | | Option | Type | Default | Description | | ------------------------------- | ------- | ------- | -------------------------------------------- | | `--num-results` | integer | `5` | Number of results | | `--highlights, --no-highlights` | boolean | `true` | Include highlight snippets in each result | | `--json` | text | — | Full JSON payload (overrides flags) | | `--exa-api-key` | text | — | Override the Exa API key for this invocation | # gtm gmail Source: https://gtmsdk.com/cli/gmail Command reference for gtm gmail — Gmail commands. Generated from the CLI help text. Gmail commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## `gtm gmail url` Gmail URL decoding. ### `gtm gmail url decode` Decode a Gmail URL or token to a hex API ID, optionally fetching the message. ```bash theme={"system"} uv run gtm gmail url decode [OPTIONS] URL_OR_TOKEN ``` | Argument | Type | Required | | -------------- | ---- | -------- | | `URL_OR_TOKEN` | text | yes | | Option | Type | Default | Description | | --------------- | ------- | ----------------- | -------------------------------------------------------------------------- | | `--read, -r` | boolean | `false` | Fetch the message via gws after decoding | | `--format, -f` | choice | `ReadFormat.text` | Output format when --read is set | | `--html` | boolean | `false` | Return HTML body instead of plain text (text format only) | | `--headers, -H` | boolean | `false` | Include parsed headers (text format only; json/markdown always carry them) | # gtm granola Source: https://gtmsdk.com/cli/granola Command reference for gtm granola — Granola local export commands. Generated from the CLI help text. Granola local export commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Commands ### `gtm granola export` ```bash theme={"system"} uv run gtm granola export [OPTIONS] ``` | Option | Type | Default | Description | | ---------- | ------- | ---------------------------------------------------------- | ------------------------------ | | `--source` | choice | `hybrid` | Export source strategy | | `--output` | path | `/Users/elvis/Documents/elviskahoro/zotero/zotero-granola` | Output root | | `--since` | text | — | Optional ISO timestamp filter | | `--debug` | boolean | `false` | Print debug output | | `--json` | text | — | JSON payload (overrides flags) | # CLI overview Source: https://gtmsdk.com/cli/index The gtm command line: invocation forms, command groups, root commands, and the shared conventions every command follows. The `gtm` CLI is the front door to the SDK: ten command groups covering account research, CRM sync, web search, enrichment, meeting exports, and webhook operations. ## Invocation The package installs a `gtm` entrypoint; both forms are equivalent: ```bash theme={"system"} uv run gtm [OPTIONS] uv run python -m cli.main [OPTIONS] ``` Two root-level commands exist outside the groups: `gtm hello` (smoke test) and `gtm version` (prints `gtm v`). ## Command groups | Group | Covers | | ----------------------------------- | ---------------------------------------------------------------------- | | [`gtm accounts`](/cli/accounts) | GTM workflows: research, enrichment, people discovery, batch CRM loads | | [`gtm apollo`](/cli/apollo) | Apollo people and organization enrichment and search | | [`gtm attio`](/cli/attio) | Attio CRM: people, companies, notes, enrichment backfills | | [`gtm enrichment`](/cli/enrichment) | LinkedIn enrichment via Harvest | | [`gtm exa`](/cli/exa) | Exa web search, company and people discovery | | [`gtm gmail`](/cli/gmail) | Gmail URL and token decoding | | [`gtm granola`](/cli/granola) | Local-first Granola meeting exports | | [`gtm parallel`](/cli/parallel) | Parallel web search, content extraction, entity discovery | | [`gtm sanity`](/cli/sanity) | Sanity CMS content downloads | | [`gtm webhook`](/cli/webhook) | Webhook registry: Modal endpoints + Hookdeck wiring | ## Shared conventions Every command follows the [CLI contract](/concepts/cli-contract): * Success data is **JSON on stdout**; errors and logs go to stderr. * Mutations **preview by default** — `--apply` (or `--modal-sync` on Attio people commands) is required to write. * Provider-backed commands **execute on your deployed Modal app**; local-only commands (`version`, `gmail url decode`, `granola export`, `sanity blog download`, `webhook list`) need no deployment. * Every command accepts `--json` with a full payload that overrides individual flags. * Keys resolve per [Secrets and API keys](/secrets), with per-invocation `---api-key` overrides. The per-group reference pages are generated from the CLI's own `--help` text by `scripts/docs-cli_reference-generate.py`, and CI fails if they drift from the code — what you read here is what the binary ships. # gtm parallel Source: https://gtmsdk.com/cli/parallel Command reference for gtm parallel — Parallel API commands. Generated from the CLI help text. Parallel API commands. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Examples Search the web, extract content, or run an entity-discovery job: ```bash theme={"system"} uv run gtm parallel search query "best CRM for startups" --mode agentic --max 15 ``` ```bash theme={"system"} uv run gtm parallel extract excerpts "https://acme.dev/pricing" \ "Extract pricing tiers and features" ``` FindAll runs are asynchronous — create, poll, then fetch results: ```bash theme={"system"} uv run gtm parallel findall create \ "Find YC-backed AI startups founded in 2024" \ "company" \ '[{"field": "funding", "operator": "contains", "value": "YC"}]' \ --limit 50 uv run gtm parallel findall status uv run gtm parallel findall result ``` ## `gtm parallel extract` Extract content from URLs using Parallel API. ### `gtm parallel extract excerpts` Extract focused excerpts from a URL matching an objective. ```bash theme={"system"} uv run gtm parallel extract excerpts [OPTIONS] [URL] [OBJECTIVE] ``` | Argument | Type | Required | | ----------- | ---- | -------- | | `URL` | text | no | | `OBJECTIVE` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm parallel extract full` Extract full content from a URL. ```bash theme={"system"} uv run gtm parallel extract full [OPTIONS] [URL] ``` | Argument | Type | Required | | -------- | ---- | -------- | | `URL` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ## `gtm parallel findall` Discover entities using Parallel FindAll API. ### `gtm parallel findall create` Start a FindAll entity discovery run. ```bash theme={"system"} uv run gtm parallel findall create [OPTIONS] [OBJECTIVE] [ENTITY_TYPE] [CONDITIONS] ``` | Argument | Type | Required | | ------------- | ---- | -------- | | `OBJECTIVE` | text | no | | `ENTITY_TYPE` | text | no | | `CONDITIONS` | text | no | | Option | Type | Default | Description | | -------------------- | ------- | ------- | ------------------------------------------------- | | `--limit, -n` | integer | `10` | Max matches (5-1000) | | `--generator, -g` | text | `base` | Generator: base, core, pro, preview | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm parallel findall result` Get results from a completed FindAll run. ```bash theme={"system"} uv run gtm parallel findall result [OPTIONS] [FINDALL_ID] ``` | Argument | Type | Required | | ------------ | ---- | -------- | | `FINDALL_ID` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ### `gtm parallel findall status` Check the status of a FindAll run. ```bash theme={"system"} uv run gtm parallel findall status [OPTIONS] [FINDALL_ID] ``` | Argument | Type | Required | | ------------ | ---- | -------- | | `FINDALL_ID` | text | no | | Option | Type | Default | Description | | -------------------- | ---- | ------- | ------------------------------------------------- | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | ## `gtm parallel search` Search the web using Parallel API. ### `gtm parallel search query` Search the web for a given objective. ```bash theme={"system"} uv run gtm parallel search query [OPTIONS] [OBJECTIVE] ``` | Argument | Type | Required | | ----------- | ---- | -------- | | `OBJECTIVE` | text | no | | Option | Type | Default | Description | | -------------------- | ------- | ---------- | ------------------------------------------------- | | `--mode, -m` | text | `one-shot` | Search mode: one-shot, agentic, fast | | `--max, -n` | integer | `10` | Max results (1-20) | | `--json` | text | — | JSON payload (overrides flags) | | `--parallel-api-key` | text | — | Override the Parallel API key for this invocation | # gtm sanity Source: https://gtmsdk.com/cli/sanity Command reference for gtm sanity — Download content from Sanity. Generated from the CLI help text. Download content from Sanity. Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## `gtm sanity blog` Download blog posts from Sanity. ### `gtm sanity blog download` Download every blog post to \/blogs/\/ (index.md + post.json). ```bash theme={"system"} uv run gtm sanity blog download [OPTIONS] ``` | Option | Type | Default | Description | | --------------------------------- | ------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--out-dir` | path | `out` | Output directory; posts are written under \/blogs/\/. | | `--project-id` | text | `nsq559ov` | Sanity project ID | | `--dataset` | text | `production` | Sanity dataset name | | `--api-version` | text | `v2025-02-19` | Sanity API version date string | | `--token` | text | — | Bearer token for private datasets (public datasets need none). | | `--use-cdn, --no-cdn` | boolean | `false` | Read from the cached CDN edge instead of the live origin. Off by default so an archive run never captures stale content or misses just-published/just-deleted posts. | | `--use-env-token, --no-env-token` | boolean | `false` | Off by default so a public archive run ignores any ambient SANITY\_API\_TOKEN and stays reproducible. Pass --use-env-token to authenticate from the environment (an explicit --token always applies regardless). | | `--prune, --no-prune` | boolean | `true` | Remove snapshots of posts no longer in the live corpus. | # gtm webhook Source: https://gtmsdk.com/cli/webhook Command reference for gtm webhook — Webhook URL registry (Modal endpoints + Hookdeck wiring). Generated from the CLI help text. Webhook URL registry (Modal endpoints + Hookdeck wiring). Every command follows the [CLI contract](/concepts/cli-contract): JSON on stdout, errors on stderr, and command-specific mutation safety controls. Keys resolve as described in [Secrets and API keys](/secrets). ## Commands ### `gtm webhook sync` Regenerate webhooks/registry.yaml from live Modal + Hookdeck state. ```bash theme={"system"} uv run gtm webhook sync [OPTIONS] ``` ### `gtm webhook list` Print the cached registry as JSON. Errors if no cache; run `sync` first. ```bash theme={"system"} uv run gtm webhook list [OPTIONS] ``` # Architecture Source: https://gtmsdk.com/concepts/architecture The enforced cli → src → libs layering, the repo layout, hard rules and anti-patterns, and the decision tree for where new code goes. gtm-sdk is three layers with one-way dependencies: the CLI calls orchestration, orchestration chains adapters, and adapters wrap exactly one external service each. The layering is enforced in review and by convention, not just described here. ## Repo layout ```text theme={"system"} 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 products (independent, composable). ├── webhooks/ # Standalone Modal webhook handlers, one app per (handler, source) pair. ├── api/ │ ├── specs/ # External API OpenAPI specs (cal.com, Sanity). Read-only reference. │ └── samples/ # Redacted real payloads (rb2b, cal.com, Fathom, Octolens) used as fixtures. ├── tests/ # pytest, importlib mode. Mirrors cli/, libs/, src/. ├── tmp/ # Gitignored scratch. ALL temporary files go here. ├── docs/ # This documentation site (Mintlify). ├── deploy.py # Modal deploy entrypoint (must stay at root — avoids `attio` pkg shadowing). └── pyproject.toml ``` ## Layer rules * `libs//` wraps **one** external SDK or API with idiomatic Python types and functions. **No `libs/` may import from `libs/`** — if two adapters need to coordinate, that belongs in `src/`. * `src/` chains adapters into workflows and owns side effects. Modal `@app.function` and `@modal.fastapi_endpoint` decorators live here, never in `libs/`. * `cli/` is Typer-only: parse arguments → preflight → call into `src/` → render output. No business logic. * `data-gen/` products are independent; they do not depend on each other. Anti-patterns to reject in review: orchestration inside `libs/`, business logic inside `cli/`, cross-lib imports. ## Where new code goes 1. **External SDK call?** → New file in `libs//`. Wrap one SDK only. 2. **Multi-step flow or Modal endpoint?** → `src//`. If it defines endpoints, register the module in `_ENDPOINT_MODULES` in `src/app.py`, or its decorators never run. 3. **User-facing command?** → `cli//` as a Typer subapp, wired into `cli/main.py` via `app.add_typer(...)`. 4. **Standalone data product?** → `data-gen//`, self-contained. 5. **Webhook handler?** → `webhooks/.py`, an independent Modal app (never registered in `src/app.py`). Adding a new top-level package also means updating `[tool.setuptools.packages.find]` in `pyproject.toml` (currently `cli*`, `libs*`, `scripts*`, `src*`). ## The adapter pattern Every keyed adapter exposes the same client shape: a `get_client()` (or module-level functions that build one) with a three-tier key resolution — explicit `api_key=` argument, then the `api_key_scope` contextvar, then the adapter's environment variable. [Secrets and API keys](/secrets) covers why. ## Execution model The CLI is a thin client: provider-backed commands invoke functions deployed on Modal by name rather than calling provider APIs from your machine. Local-only commands (`gtm version`, `gmail url decode`, `granola export`, `sanity blog download`, `webhook list`) run in-process. The [CLI contract](/concepts/cli-contract) page describes the I/O and mutation-gating conventions shared by every command. ## Related directories * `api/specs/` and `api/samples/` are read-only reference: OpenAPI specs for external services and redacted real webhook payloads used as test fixtures. Webhook models are validated against captured payloads, not hand-authored fixtures. * `data-gen/` currently ships one product (`marketplace`, behind `uv sync --extra marketplace`). * `tests/` mirrors the source layout; see [Development](/contributing/development). # CLI contract Source: https://gtmsdk.com/concepts/cli-contract The I/O conventions every gtm command follows: JSON on stdout, errors on stderr, preview-by-default mutations, remote execution on Modal, and the --json override. Every `gtm` command follows the same contract. Learn it once and every command group — `accounts`, `apollo`, `attio`, `exa`, `parallel`, and the rest — behaves predictably, whether a person or an agent is driving. ## JSON on stdout, everything else on stderr Success output is JSON printed to stdout. Progress, warnings, and errors go to stderr. That makes every command safe to pipe: ```bash theme={"system"} uv run gtm attio people search --email-domain "acme.com" | jq '.[].email' ``` Runtime errors print to stderr prefixed with `Error:` and exit `1`. Usage errors exit `2` (and emit a `cli.usage_error` telemetry event with the raw arguments). | Exit code | Meaning | | --------- | ------------------------------------------------ | | `0` | Success — stdout holds the JSON result. | | `1` | Runtime error — message on stderr, stdout empty. | | `2` | Usage error — bad flags or arguments. | ## Mutation safety is command-specific Commands that write to external systems expose the safety control appropriate to their workflow. Commands with preview/apply behavior print the planned mutation and write nothing until `--apply` is supplied: ```bash theme={"system"} # Preview: prints what would be written to Attio, writes nothing. uv run gtm accounts batch-add-people \ --records '[{"email": "jane@acme.dev", "first_name": "Jane", "last_name": "Doe"}]' # Execute: same command plus --apply. uv run gtm accounts batch-add-people \ --records '[{"email": "jane@acme.dev", "first_name": "Jane", "last_name": "Doe"}]' \ --apply ``` The people mutation commands are different: `attio people add`, `update`, and `upsert` write when invoked. Their `--modal-sync check|deploy|skip` option controls only the Modal deployment-parity preflight before the write; it is not a preview/apply switch. The preview/apply controls are: * **`--apply`** — on `accounts batch-add-people`, `accounts batch-add-companies`, `attio companies create-attribute-type`, and `attio enrichment backfill-domains`. Default is a preview that performs no writes. * **`--modal-sync check|deploy|skip`** — on `attio people` mutations (`add`, `update`, `upsert`). This governs the Modal deployment-parity preflight before the mutation runs: `check` (default) verifies the deployed app matches your local code, `deploy` refreshes it first, and `skip` bypasses the preflight. These commands also accept `--no-connectivity-probe` to skip the Modal reachability check; neither option prevents the Attio write. Review the command’s mutation behavior before running it. In particular, people mutations write immediately; use `upsert` when the intended behavior is create-or-update. ## Remote execution on Modal Provider-backed commands do not call provider APIs from your machine. The CLI validates your input, then invokes a function on your deployed Modal app by name (`modal.Function.from_name(MODAL_APP, ...)`) and prints the JSON it returns. The app name comes from the `MODAL_APP` environment variable, defaulting to `gtm-sdk`. This means keyed commands need a deployed app (`uv run modal deploy deploy.py`) and Modal credentials — see the [quickstart](/quickstart) and [Secrets and API keys](/secrets). Local-only commands (`version`, `hello`, `gmail url decode`, `granola export`, `sanity blog download`, `webhook list`) work with no deployment at all. ## The `--json` override Every command that builds a request from flags also accepts `--json` with a complete payload, which overrides the flags. Useful when an agent already holds a structured request: ```bash theme={"system"} uv run gtm exa search --json '{"query": "developer-first CRM tools", "num_results": 5}' ``` Payloads are validated against the same Pydantic models the SDK uses; validation failures print the model error to stderr and exit `1`. ## API-key override flags Commands that call a provider accept a per-invocation key override (`--exa-api-key`, `--attio-api-key`, `--apollo-api-key`, `--parallel-api-key`, …). The value is forwarded to the deployed function and applied only for that call — the resolution order is documented in [Secrets and API keys](/secrets). # Development guide Source: https://gtmsdk.com/contributing/development Setup, everyday commands, layer rules, testing, and conventions for contributing to the gtm-sdk repository. ## Setup The project requires Python 3.13 (`>=3.13,<3.14`) and uses `uv` as the only supported package manager — never `pip`, `pip3`, or `python3 -m pip`. `uv` resolves everything against the committed `uv.lock`, which guarantees a deterministic, reproducible environment across machines and CI; bare `pip` bypasses that lock file and causes environment drift. ```bash theme={"system"} git clone https://github.com/elviskahoro/gtm-sdk.git cd gtm-sdk ``` ```bash theme={"system"} uv sync ``` This installs the runtime dependencies and the `dev` dependency group (pytest, bandit, and friends) from `uv.lock`. ```bash theme={"system"} uv run gtm --help ``` ## Everyday commands Run everything through `uv run` so it executes inside the project virtual environment. ```bash theme={"system"} uv sync # install/lock deps uv run gtm --help # list CLI command groups uv run gtm --help # list commands in a group uv run pytest # test suite (integration tests excluded) trunk check --all # lint + typecheck (ruff, pyright, bandit, ...) trunk fmt # format changed files ``` `uv run pytest` uses `--import-mode=importlib` and excludes integration tests by default — both are configured in `pyproject.toml` under `[tool.pytest.ini_options]` via `addopts = "--import-mode=importlib -m 'not integration'"`. ## Linting All linters and formatters run through **trunk**, never as bare binaries. Tools like `ruff`, `bandit`, `yamllint`, `shellcheck`, `prettier`, and `actionlint` live inside trunk's sandbox — invoking them directly either fails with `command not found` or picks up the wrong configuration. ```bash theme={"system"} trunk check --all # everything, whole repo trunk check --filter=ruff libs/attio/ # reproduce a single tool's finding trunk fmt cli/main.py # format one file ``` To reproduce a specific finding from CI, use `trunk check --filter= ` with the tool name from the CI output. ## Layer rules The repository enforces a strict layered architecture: * `libs//` wraps **one** external SDK or API with idiomatic Python types and functions. **No `libs/` module may import from `libs/`.** If two adapters need to coordinate, that coordination belongs in `src/`. * `src/` is the orchestration layer: multi-step workflows, side effects, and Modal `@app.function` / `@modal.fastapi_endpoint` decorators. Adapter modules in `libs/` must stay callable in isolation — no orchestration inside `libs/`. * `cli/` is Typer-only: parse arguments → preflight → call into `src/` → render output. **No business logic in `cli/`.** If you add a new top-level package, update `[tool.setuptools.packages.find]` in `pyproject.toml` — otherwise the package is not importable after install. ### Where new code goes 1. **External SDK call?** New file in `libs//`. Wrap one SDK only, no cross-lib imports. 2. **Multi-step flow or Modal endpoint?** `src//`. If the module defines Modal endpoints, add its import to `_ENDPOINT_MODULES` in `src/app.py` so the decorators register. 3. **User-facing command?** `cli//` as a Typer subapp that calls into `src/`. Wire it into `cli/main.py` via `app.add_typer(...)`. 4. **Standalone data product?** `data-gen//`. Self-contained and independent of other data products. 5. **Webhook handler?** `webhooks/.py`. Each handler is an independent Modal app — do not register it in `src/app.py`. ## Testing ```bash theme={"system"} uv run pytest # unit tests (integration excluded by default) uv run pytest tests/cli # one subtree uv run pytest -m integration # integration tests (require live credentials) ``` * Tests mirror the source layout: `tests/cli/`, `tests/libs/`, `tests/src/`, and `tests/integration/`. Put new tests in the directory that mirrors the code under test. * Tests that hit live external APIs carry the `integration` marker, defined in `pyproject.toml`. The default `addopts` filter (`-m 'not integration'`) keeps them out of ordinary runs, so `uv run pytest` never needs credentials. * Plain `assert` statements are allowed in tests — ruff's `S101` rule is ignored for `tests/**` via a per-file ignore in `pyproject.toml`. ## Conventions * **Temporary files go in `tmp/` only.** The directory is gitignored. Never write scratch output to the repo root or next to source code. * **Anchor script file I/O on the script's own directory, not the CWD.** `uv run path/to/script.py` does **not** change the working directory, so relative paths resolve from wherever the command was invoked and can silently write files to the wrong place: ```python theme={"system"} SCRIPT_DIR = Path(__file__).resolve().parent (SCRIPT_DIR / "output.txt").write_text(...) ``` * **Scripts under `scripts/` are directly executable** with `uv` shebangs where practical. Scripts that need Infisical secrets must show the full flag form in their usage text (`--projectId`, `--token`, `--env`) rather than assuming `infisical init` has run: ```bash theme={"system"} set -a && source .env.local && set +a infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env= -- ``` * **Docstrings explain *why*, not *what*.** Document decisions and gotchas inline, next to the code they affect. * **No summary or investigation `.md` files.** Live documentation belongs in code (docstrings, per-module READMEs); the docs site under `docs/` is the only place for prose documentation. ## Docs site Documentation pages live in `docs/` and are built with Mintlify. Local preview requires Node 24 (pinned in `docs/.node-version`): install the CLI with `npm i -g mint`, then run `mint dev` from inside `docs/`. Every page needs `title` and `description` frontmatter — the description becomes the page's `llms.txt` entry. # gtm-sdk: Go-To-Market SDK and CLI for Modal Source: https://gtmsdk.com/index Layered Python SDK and Typer CLI for account research, enrichment, CRM sync, and outreach workflows, deployed as Modal serverless functions. **gtm-sdk** is a layered Go-To-Market toolkit: a thin Typer CLI on top of workflow orchestration on top of single-SDK adapters. It deploys as Modal serverless functions and is consumable as an editable Python package. With one CLI you can: * **Research and enrich accounts** — `gtm accounts research`, `gtm apollo people enrich` * **Sync your CRM** — `gtm attio people upsert`, `gtm attio companies add` * **Search and extract from the web** — `gtm exa search`, `gtm parallel extract` * **Export meetings** — `gtm granola export`, plus Cal.com, Fathom, and Fireflies webhook pipelines into Attio * **Track visitors and mentions** — RB2B and Octolens webhook handlers with CRM export ## Architecture The repo enforces a strict layering: | Layer | Directory | Role | | ------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | | CLI | `cli/` | Thin command surface. Parse flags, preflight, call `src/`, render output. No business logic. | | Orchestration | `src/` | Multi-step workflows chaining adapters. Modal `@app.function` / `@modal.fastapi_endpoint` decorators live here. | | Adapters | `libs/` | One folder per external service (Attio, Apollo, Exa, Parallel, …). No cross-lib imports. | | Data products | `data-gen/` | Independent, composable data generation and enrichment pipelines. | | Webhooks | `webhooks/` | Standalone Modal webhook handlers, deployed individually. | The [architecture page](/concepts/architecture) covers the layer rules and where new code goes. ## Start here Install dependencies and run your first command in five minutes. Every command group, generated from the CLI's own help text. Provider keys via flags, environment variables, or Infisical. Wire the OTEL collector to Dash0, Grafana Cloud, and other providers. ## Built for agents Every page here is machine-readable: the site serves [`/llms.txt`](https://elviskahoro.mintlify.app/llms.txt), Markdown variants of every page (append `.md` to any URL), and a hosted MCP server. If you are pointing an AI agent at this SDK, start with [Using these docs with AI tools](/ai-tools). # Installation Source: https://gtmsdk.com/installation Install gtm-sdk standalone or as an editable dependency of another uv project, with Python 3.13 and uv as the only supported package manager. ## Requirements * Python `>=3.13,<3.14` * [`uv`](https://docs.astral.sh/uv/) — the only supported package manager. Bare `pip` bypasses `uv.lock` and causes environment drift. ## Standalone ```bash theme={"system"} git clone git@github.com:elviskahoro/gtm-sdk.git cd gtm-sdk uv sync ``` `uv sync` installs from the committed lock file, so every machine resolves the exact same dependency set. ## As an editable dependency To consume the SDK from another uv project, clone it inside that project (or add it as a git submodule) and point a uv source at the path: ```bash theme={"system"} git clone git@github.com:elviskahoro/gtm-sdk.git gtm-sdk ``` ```toml pyproject.toml theme={"system"} [project] dependencies = ["gtm"] [tool.uv.sources] gtm = { path = "gtm-sdk", editable = true } ``` Then run `uv sync` in the parent project. The `cli`, `src`, and `libs` packages become importable, and edits inside `gtm-sdk/` are visible immediately — no reinstall, no pin to bump. ## Optional extras The `marketplace` data product needs Polars, which is not installed by default: ```bash theme={"system"} uv sync --extra marketplace ``` ## Verify ```bash theme={"system"} uv run gtm version ``` ```text Output theme={"system"} gtm v0.1.0 ``` Continue with the [quickstart](/quickstart) to run your first commands. # Quickstart: install gtm-sdk and run your first command Source: https://gtmsdk.com/quickstart Clone gtm-sdk, install dependencies with uv, run local CLI commands, then wire credentials and a Modal deployment for the provider-backed commands. ## Prerequisites * Python `>=3.13,<3.14` * [`uv`](https://docs.astral.sh/uv/) — the only supported package manager (never bare `pip`) * For provider-backed commands: a [Modal](https://modal.com/) account and either provider API keys or an [Infisical](https://infisical.com/) project (see [Secrets and API keys](/secrets)) ## Get started ```bash theme={"system"} git clone git@github.com:elviskahoro/gtm-sdk.git cd gtm-sdk ``` Consuming the SDK from another project instead? See [Installation](/installation). ```bash theme={"system"} uv sync ``` The package installs a `gtm` entrypoint: ```bash theme={"system"} uv run gtm version ``` ```text Output theme={"system"} gtm v0.1.0 ``` `uv run gtm --help` lists the command groups: `accounts`, `apollo`, `attio`, `enrichment`, `exa`, `gmail`, `granola`, `parallel`, `sanity`, and `webhook`. Some commands run entirely on your machine — no keys, no deployment. Decode a Gmail web URL to its API message ID: ```bash theme={"system"} uv run gtm gmail url decode "https://mail.google.com/mail/u/0/#inbox/FMfcgzQgLPNbWJPPscDGRbsGngFwlCpq" ``` ```text Output theme={"system"} 19d89702e6cb6456 ``` Commands that touch external providers (Exa, Attio, Apollo, Parallel, …) execute as functions on **your Modal deployment** — the CLI is a thin client that invokes them by name. Two things make those commands work: 1. **A deployed app.** Authenticate with Modal (`uv run modal token new`), then: ```bash theme={"system"} uv run modal deploy deploy.py ``` 2. **Provider keys.** Pass them per command with API-key flags, or let the deployed functions fetch them from Infisical at entry: ```bash API-key flags theme={"system"} # No Infisical required: pass the provider key directly on the command. # The flag rides the call to the deployed Modal function and overrides # the environment inside the container for that one invocation. uv run gtm exa search "developer-first CRM tools" --exa-api-key ``` ```bash Infisical theme={"system"} # One-time: copy the template and fill in your Infisical credentials. cp .env.example .env.local # Once per shell: load the bootstrap credentials. set -a && source .env.local && set +a # Keys are fetched from Infisical at function entry — no per-service flags. infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env=prod -- \ uv run gtm exa search "developer-first CRM tools" ``` The [Secrets and API keys](/secrets) page explains the resolution order and the Infisical bootstrap in full. ## Quality gates ```bash theme={"system"} trunk check --all # lint + type check uv run pytest # tests (integration tests excluded by default) ``` ## Next steps Key resolution order, Infisical bootstrap, and the env-var reference. JSON on stdout, errors on stderr, preview-by-default mutations. The enforced cli → src → libs layering and where new code goes. OTEL collector fan-out to Dash0, HyperDX, Logfire, and Grafana. # Secrets and API keys Source: https://gtmsdk.com/secrets How gtm-sdk resolves provider API keys: explicit arguments, contextvar scopes, environment variables, and Infisical-backed hydration for Modal functions. Every adapter in `libs/` needs a provider API key, and all of them resolve keys the same way. This page covers the resolution order, the two ways to supply credentials, and how keys reach functions running on Modal. ## Two ways to provide keys ```bash API-key flags theme={"system"} # No Infisical required: pass the provider key directly on the command. # The flag rides the call to the deployed Modal function and overrides # the environment inside the container for that one invocation. uv run gtm exa search "developer-first CRM tools" --exa-api-key ``` ```bash Infisical theme={"system"} # One-time: copy the template and fill in your Infisical credentials. cp .env.example .env.local # Once per shell: load the bootstrap credentials. set -a && source .env.local && set +a # Keys are fetched from Infisical at function entry — no per-service flags. infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env=prod -- \ uv run gtm exa search "developer-first CRM tools" ``` * **API-key flags** work without any vault. Commands that call a provider accept an override flag (`--exa-api-key`, `--attio-api-key`, `--apollo-api-key`, …) that is forwarded to the deployed Modal function for that single invocation. * **Infisical** is the zero-flags path: the CLI ships your Infisical bootstrap credentials to the Modal function, which fetches the keys it needs at entry. ## Key resolution inside `libs/` Each adapter's client resolves its key in a fixed order, documented in the client module (for example `libs/exa/client.py`): 1. An explicit `api_key=` argument — used by tests and one-off scripts. 2. The contextvar bound by `api_key_scope(...)` — opened by webhook handlers and Modal functions after fetching the key from Infisical. 3. The adapter's environment variable (for example `EXA_API_KEY`) — the fallback for plain-environment setups. ```python theme={"system"} from libs.exa.client import api_key_scope from libs.exa.search import search with api_key_scope(""): response = search(payload) ``` The contextvar scope exists for concurrency: multiple Modal inputs can run in the same container, and a contextvar keeps keys from leaking between requests. Prefer `api_key_scope` over mutating `os.environ` in long-lived processes. ## The `.env.local` bootstrap (Infisical) `.env.local` holds only the credentials that get you *into* the vault — every provider secret lives in Infisical itself, never in the file: ```bash theme={"system"} cp .env.example .env.local # then fill in the values set -a && source .env.local && set +a ``` | Variable | Purpose | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INFISICAL_TOKEN` | Service token or machine-identity access token for the project. | | `INFISICAL_PROJECT_ID` | The Infisical project the token points at. | | `INFISICAL_ENV` | Environment slug to read against. Required at fetch time — there is no default, by design: a silent `dev` default could route production traffic at dev credentials. Match your project's slugs (for example `dev`, `stg`, `prod`). | | `INFISICAL_HOST` | Optional. Only for self-hosted Infisical; defaults to `https://app.infisical.com`. | There is no `.infisical.json` in the repo, so the `infisical` CLI does not auto-detect the project — always pass `--projectId`, `--token`, and `--env` explicitly, as shown in every example on this site. ## Keys at runtime on Modal Keyed CLI commands do not call providers from your laptop — they invoke functions deployed on Modal (see the [CLI contract](/concepts/cli-contract)). Keys reach those functions through a bootstrap pattern in `src/secrets_bootstrap.py`: 1. Each function is declared with `@with_secrets("_API_KEY")` and `secrets=[bootstrap_secret()]`. 2. `bootstrap_secret()` captures your `INFISICAL_*` credentials from the deploy-time shell and ships them to Modal as a server-side secret — they never appear in image layers or build logs. 3. At function entry, the wrapper fetches each declared key from Infisical and opens the matching `api_key_scope` for the duration of the call. The set of keys the bootstrap can bind is `KEY_SCOPES` in `src/secrets_bootstrap.py`: `APOLLO_API_KEY`, `ATTIO_API_KEY`, `CALCOM_API_KEY`, `EXA_API_KEY`, `LINEAR_API_KEY`, `PARALLEL_API_KEY`, and `SLACK_BOT_TOKEN`. Do not bind named Modal secrets with `modal.Secret.from_name(...)` — the bootstrap pattern replaced them. Adding a new key means: wire an `api_key_scope` contextvar in `libs//client.py`, add the `"_API_KEY"` entry to `KEY_SCOPES`, and decorate the function with `@with_secrets("_API_KEY")`. ## Environment variable reference Provider keys, one per adapter: | Environment variable | Adapter | | -------------------- | ---------------------------------------- | | `APOLLO_API_KEY` | `libs/apollo` | | `ATTIO_API_KEY` | `libs/attio` | | `CALCOM_API_KEY` | `libs/caldotcom` | | `EXA_API_KEY` | `libs/exa` | | `FATHOM_API_KEY` | `libs/fathom` | | `GRANOLA_API_KEY` | `libs/granola` (API source mode) | | `HARVEST_API_KEY` | `libs/harvest` | | `HOOKDECK_API_KEY` | `gtm webhook sync` (registry generation) | | `LINEAR_API_KEY` | `libs/linear` | | `OCTOLENS_API_KEY` | `libs/octolens` | | `PARALLEL_API_KEY` | `libs/parallel` | | `RESEND_API_KEY` | `libs/resend` | | `SANITY_API_TOKEN` | `libs/sanity` | | `SLACK_BOT_TOKEN` | `libs/slack` | Telemetry has its own set of producer- and collector-side variables — see the [telemetry overview](/telemetry/overview). # Send gtm-sdk telemetry to Dash0 with the otel-collector Source: https://gtmsdk.com/telemetry/dash0-setup Send OpenTelemetry traces and logs from the gtm-sdk otel-collector to Dash0 with regional endpoints, Infisical secret config, and Modal deploy. This guide walks through setting up Dash0 as a telemetry provider for the `otel-collector` Modal app. The collector fans out every batch to all configured providers; the architecture itself is covered in [Telemetry overview](/telemetry/overview). This page covers only the Dash0-specific wiring: the collector registers an `otlphttp/dash0` exporter and sends the `Dash0-Dataset` header when `DASH0_DATASET` is set. ## Endpoints Dash0 provides regional endpoints: * **US**: `https://otel-ingest.us.dash0.com` * **EU**: `https://otel-ingest.eu.dash0.com` Choose the one nearest your data location. ## Setup Add three secrets to your Infisical project (prod environment): | Name | Value | Required | | --------------------- | ---------------------------------------------------------------------------------------------------------- | -------- | | `DASH0_AUTH_TOKEN` | Your Dash0 API token | Yes | | `DASH0_OTLP_ENDPOINT` | Your regional endpoint: `https://otel-ingest.us.dash0.com` (US) or `https://otel-ingest.eu.dash0.com` (EU) | Yes | | `DASH0_DATASET` | `default`, or your dataset name | No | You can set them from the CLI. Run this from the repo root: ```bash theme={"system"} # Source your Infisical credentials set -a && source .env.local && set +a # Set each secret infisical secrets set \ DASH0_AUTH_TOKEN "" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" infisical secrets set \ DASH0_OTLP_ENDPOINT "https://otel-ingest.us.dash0.com" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" infisical secrets set \ DASH0_DATASET "default" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" ``` Once the secrets are set, deploy the `otel-collector` app: ```bash theme={"system"} set -a && source .env.local && set +a infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" \ --env=prod -- uv run modal deploy src/otel_collector.py ``` The collector image build fetches the secrets and embeds them, so when the container starts, Dash0 is already configured. Check that Dash0 was included in the collector config: ```bash theme={"system"} uv run python -c " from src.otel_collector import build_collector_config config = build_collector_config() exporters = config.get('exporters', {}) print('Exporters:', list(exporters.keys())) if 'otlphttp/dash0' in exporters: print('Dash0 is configured') else: print('Dash0 is NOT configured') " ``` ```text Output theme={"system"} Exporters: ['otlphttp/dash0'] Dash0 is configured ``` Use the verification script to test end-to-end telemetry: ```bash theme={"system"} # Set environment (usually injected via Infisical, but can override) export DASH0_AUTH_TOKEN="" export DASH0_OTLP_ENDPOINT="https://otel-ingest.us.dash0.com" # Collector fan-out is the default (app name hard-coded in libs/telemetry.py); # only set this to override the app name, or to "" to force the direct fallback. export TELEMETRY_COLLECTOR_APP="otel-collector" # Run tests uv run pytest tests/src/test_dash0_telemetry_integration.py -v # Or emit test telemetry (requires collector running on Modal) uv run scripts/dash0-telemetry-verify.py ``` ## How Dash0 fits into the collector Apps never talk to Dash0 directly. The app-side exporter in `libs/telemetry.py` serializes each batch to OTLP protobuf and spawns the collector Modal function fire-and-forget; a localhost otelcol sidecar in that container handles retry, queueing, and fan-out to every configured provider, Dash0 included. See [Telemetry overview](/telemetry/overview) for the full architecture, and `src/otel_collector.py` for the config builder. ## Troubleshooting Verify the secrets exist in Infisical: ```bash theme={"system"} set -a && source .env.local && set +a infisical secrets get DASH0_AUTH_TOKEN --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" ``` Check the Modal logs for the `otel-collector` app: ```bash theme={"system"} set -a && source .env.local && set +a infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" \ --env=prod -- uv run modal app logs otel-collector ``` Look for errors like `exporters config: *conf.ExportersConfig.Validate()...`, which indicates a config build error. 1. Verify the collector is running: `uv run modal serve src/otel_collector.py` 2. Check Modal logs for export errors 3. Verify the OTLP endpoint is reachable from the collector container 4. Check that the Bearer token is valid You can enable multiple providers simultaneously — the collector fans out to all configured exporters: ```bash theme={"system"} # These don't conflict; all three will export DASH0_AUTH_TOKEN=... HYPERDX_API_KEY=... LOGFIRE_WRITE_TOKEN=... ``` ## Reference * [Dash0 docs](https://dash0.com/docs) * [OpenTelemetry Collector OTLPHTTP exporter](https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlphttpexporter/README.md) * `src/otel_collector.py` — collector app and config builder * `libs/telemetry.py` — app-side telemetry initialization # Send gtm-sdk OpenTelemetry data to Grafana Cloud via otel-collector Source: https://gtmsdk.com/telemetry/grafana-setup Send traces and logs from the gtm-sdk otel-collector to Grafana Cloud OTLP using Basic auth, region-scoped endpoints, and Infisical secrets. ## Endpoints Grafana Cloud exposes a **region-scoped** OTLP gateway: ```text theme={"system"} https://otlp-gateway-prod-.grafana.net/otlp ``` Find your exact URL on the Grafana Cloud **"OpenTelemetry"** (OTLP) config page for your stack — for example `https://otlp-gateway-prod-us-east-3.grafana.net/otlp` (the collector's hard-coded default) or `https://otlp-gateway-prod-eu-west-2.grafana.net/otlp`. The collector strips any trailing `/v1/traces` / `/v1/logs` you paste, so either the base (`.../otlp`) or a full-signal URL works. ## Authentication Unlike the other providers (Dash0, HyperDX, and Logfire all use `Bearer`), Grafana Cloud's OTLP gateway authenticates with HTTP **Basic** auth. Sending a Bearer header returns a 401. ```text theme={"system"} Authorization: Basic base64(":") ``` * **``** — the numeric **OTLP-gateway instance / user id** shown on the Grafana Cloud "OpenTelemetry" config page. This is *not* the org id embedded in the `glc_` token. * **``** — a Grafana Cloud **access-policy token** (starts with `glc_`) scoped to write metrics, logs, and traces. You do **not** encode this yourself. Set the two raw values as Infisical secrets and the collector derives the base64 credential (`GRAFANA_OTLP_AUTH`) **at deploy time** (see `_grafana_basic_auth` / `_collector_secret_payload` in `src/otel_collector.py`), so the raw `glc_` token never reaches the collector container or the rendered config. ## Setup Add these secrets to your Infisical project (prod environment): | Name | Value | Required | | ----------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | `GRAFANA_INSTANCE_ID` | The numeric OTLP-gateway instance id | Yes | | `GRAFANA_API_KEY` | Your Grafana Cloud access-policy token (starts with `glc_`) | Yes | | `GRAFANA_OTLP_ENDPOINT` | Your regional OTLP gateway URL | No — omit to use the hard-coded default (`us-east-3`) | Set them via the CLI: ```bash theme={"system"} # From the gtm-sdk repo root (its own .env.local holds INFISICAL_TOKEN + INFISICAL_PROJECT_ID) set -a && source .env.local && set +a infisical secrets set \ GRAFANA_INSTANCE_ID "" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" infisical secrets set \ GRAFANA_API_KEY "" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" # Optional — only if you are not on the us-east-3 default region infisical secrets set \ GRAFANA_OTLP_ENDPOINT "https://otlp-gateway-prod-.grafana.net/otlp" \ --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" ``` Once secrets are set, deploy the otel-collector: ```bash theme={"system"} # From the gtm-sdk repo root set -a && source .env.local && set +a infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env=prod \ -- uv run modal deploy src/otel_collector.py ``` At deploy time the raw `GRAFANA_INSTANCE_ID` + `GRAFANA_API_KEY` are collapsed into the pre-encoded `GRAFANA_OTLP_AUTH` Basic credential and embedded in the collector's secret, so when the container starts Grafana is already configured. Check that Grafana was included in the collector config: ```bash theme={"system"} uv run python -c " from src.otel_collector import build_collector_config config = build_collector_config() exporters = config.get('exporters', {}) print('Exporters:', list(exporters.keys())) if 'otlphttp/grafana' in exporters: print('✓ Grafana is configured') else: print('✗ Grafana is NOT configured') " ``` ## How Grafana fits the collector Grafana is one of the fan-out targets of the shared OTEL collector: apps spawn the collector Modal function over RPC, a localhost otelcol sidecar handles retry, queueing, and fan-out, and the Grafana exporter (`otlphttp/grafana`, Basic auth) ships alongside Dash0, HyperDX, and Logfire. See [/telemetry/overview](/telemetry/overview) for the full architecture, and `libs/telemetry.py` / `src/otel_collector.py` for implementation details. ## Troubleshooting Verify the secrets exist in Infisical: ```bash theme={"system"} # From the gtm-sdk repo root set -a && source .env.local && set +a infisical secrets get GRAFANA_API_KEY --env=prod \ --projectId "$INFISICAL_PROJECT_ID" \ --token "$INFISICAL_TOKEN" ``` * Grafana Cloud uses **Basic** auth, not Bearer — a Bearer header returns 401. The collector handles this automatically; if you see 401s, re-check that `GRAFANA_INSTANCE_ID` is the numeric **OTLP-gateway instance id** (not the org id) and that the `glc_` token's access policy has write scope for the signals. * Confirm the endpoint region matches the stack the token belongs to. Check the Modal logs for the otel-collector app: ```bash theme={"system"} infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" --env=prod \ -- uv run modal app logs otel-collector ``` 1. Verify the collector is running and Grafana is in the exporter list (see the verification step above). 2. Check Modal logs for export errors. 3. Verify the OTLP endpoint region is reachable and matches the token's stack. You can enable multiple providers simultaneously — the collector fans out to all configured exporters. Grafana coexists with Dash0, HyperDX, and Logfire. ## Reference * [Grafana Cloud OTLP endpoint docs](https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/) * [OpenTelemetry Collector OTLPHTTP Exporter](https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlphttpexporter/README.md) * `src/otel_collector.py` — collector app and config builder * `libs/telemetry.py` — app-side telemetry initialization # Telemetry architecture Source: https://gtmsdk.com/telemetry/overview Architecture of the gtm-sdk OpenTelemetry pipeline: default collector fan-out over Modal RPC to Dash0, HyperDX, Logfire, and Grafana, plus the direct single-sink fallback. The SDK emits traces and logs through `libs/telemetry.py` in one of two modes: **collector fan-out** (the default) or a **direct single-sink** fallback. This page explains both modes, how to deploy the collector, and which environment variables belong where. ## Design goals Telemetry is never load-bearing. Every export path is fire-and-forget, exporter failures degrade to no-ops, and instrumenting a hot path can never fail it: * If neither mode is configured, `init_tracer` and `init_log_exporter` return `None` and every span or event call is a no-op. * If the `opentelemetry-*` packages are missing from an image, initialization prints one stderr line and degrades instead of crashing the container. * The collector's queue is in-memory, not volume-backed. A container recycle can drop an unflushed batch — that loss is acceptable by design. ## Collector fan-out (default) By default, applications never talk to a telemetry provider directly. A custom OTEL exporter in `libs/telemetry.py` serializes each batch to OTLP protobuf and fire-and-forget `.spawn()`s the collector Modal function (`src/otel_collector.py`) over Modal RPC. There is no public endpoint anywhere in this path. ```text theme={"system"} app / webhook / CLI ──.spawn(signal, otlp_bytes)──▶ fan_out (Modal function) (custom OTEL exporter serializes each batch) │ POST 127.0.0.1:4318/v1/{signal} ▼ otelcol (localhost sidecar in the same always-warm container) batch + retry + sending queue ├─▶ Dash0 ├─▶ HyperDX ├─▶ Logfire └─▶ Grafana ``` The `fan_out` function feeds each batch to a real OpenTelemetry Collector (`otelcol`) running as a localhost sidecar in the same container. The container is pinned always-warm and singular (`min_containers=1`, `max_containers=1`), so the sidecar's in-memory queue is a single shared buffer. The sidecar fans out to **all** configured providers — Dash0, HyperDX, Logfire, and Grafana — with real batching, retry, and queueing handled by `otelcol` rather than hand-rolled Python. Two properties make this the default: * **Provider credentials live only on the collector.** Application containers hold no provider tokens; they only need permission to spawn the collector function. * **No inbound attack surface.** The sidecar's OTLP receiver binds `127.0.0.1`, so it is reachable from `fan_out` in the same container but never from outside. Ingress is Modal's authenticated RPC. The collector app name is hard-coded as `DEFAULT_COLLECTOR_APP = "otel-collector"` in `libs/telemetry.py`, so collector mode works with zero producer-side configuration. Override the app with `TELEMETRY_COLLECTOR_APP=` (for example, a dev collector) and the function name with `TELEMETRY_COLLECTOR_FUNCTION` (defaults to `fan_out`). ## Direct single-sink (fallback) Set `TELEMETRY_COLLECTOR_APP=""` (explicit empty string) to opt out of collector mode. The producer then exports to a single OTLP HTTP sink resolved from `HYPERDX_API_KEY`, `HYPERDX_OTLP_ENDPOINT`, or `OTEL_EXPORTER_OTLP_ENDPOINT`. If none of those are set either, telemetry is a clean no-op. This path is intended for local development and tests. The direct path has **no Logfire exporter**. Logfire is reachable only through the collector, so an app running in direct mode silently sends nothing to Logfire — no error, no log line. If Logfire is missing data, first confirm the producer is actually in collector mode (that is, `TELEMETRY_COLLECTOR_APP` is unset or non-empty). ## Deploying the collector The collector is a **standalone Modal app** — it has its own `modal.App` and is deliberately not registered in `src/app.py`, and it exposes no web endpoint. Deploy it on its own, wrapped in Infisical so the provider credentials are present in the deploy-time environment: ```bash theme={"system"} set -a && source .env.local && set +a infisical run --projectId "$INFISICAL_PROJECT_ID" --token "$INFISICAL_TOKEN" \ --env=prod -- uv run modal deploy src/otel_collector.py ``` At deploy time, the provider credentials found in the host environment are baked into the collector's own inline Modal secret. At runtime, they select which exporters the sidecar configures — a collector deployed with no provider credentials degrades to a clean no-op rather than a boot loop. Grafana is special-cased: the raw `GRAFANA_INSTANCE_ID` and `GRAFANA_API_KEY` are deploy-time-only inputs. The deploy derives a pre-encoded Basic credential (`GRAFANA_OTLP_AUTH`) from them, so the raw token never reaches the collector container. See the Grafana guide linked below. ## Environment variables Producer-side variables configure applications, webhooks, and the CLI — the processes that emit telemetry: | Variable | Purpose | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `TELEMETRY_COLLECTOR_APP` | Collector Modal app name. Unset → hard-coded default `otel-collector`. Empty string `""` → direct single-sink fallback. | | `TELEMETRY_COLLECTOR_FUNCTION` | Function name within the collector app. Defaults to `fan_out`. | | `HYPERDX_API_KEY` | Direct mode only: HyperDX Bearer token. | | `HYPERDX_OTLP_ENDPOINT` | Direct mode only: HyperDX OTLP endpoint override. | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Direct mode only: generic OTLP base endpoint. | | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Direct mode only: per-signal logs URL (takes precedence over the base endpoint). | | `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | Direct mode only: custom auth headers for non-HyperDX sinks, read by the OTEL SDK per spec. | Collector-side provider credentials live only in the collector's secret (names from `_PROVIDER_SECRET_KEYS` in `src/otel_collector.py`): | Variable | Purpose | | ----------------------------------------- | ----------------------------------------------------------------------------------------------- | | `DASH0_AUTH_TOKEN` | Dash0 Bearer token. | | `DASH0_OTLP_ENDPOINT` | Dash0 regional ingest endpoint. Required alongside the token. | | `DASH0_DATASET` | Dash0 dataset header. Defaults to `default`. | | `HYPERDX_API_KEY` | HyperDX Bearer token. | | `HYPERDX_OTLP_ENDPOINT` | HyperDX endpoint override. Defaults to the public HyperDX ingest host. | | `LOGFIRE_WRITE_TOKEN` | Logfire write token. | | `LOGFIRE_OTLP_ENDPOINT` | Logfire endpoint override. Defaults to the public Logfire host. | | `GRAFANA_OTLP_ENDPOINT` | Grafana Cloud OTLP gateway URL (region-scoped, non-secret override). | | `GRAFANA_OTLP_AUTH` | Derived at deploy time: `base64(":")` for HTTP Basic auth. Not set by hand. | | `GRAFANA_INSTANCE_ID` / `GRAFANA_API_KEY` | Deploy-time-only raw inputs used to derive `GRAFANA_OTLP_AUTH`. Never shipped to the container. | ## Provider setup guides Regional endpoints, Infisical secrets, and dataset configuration for Dash0. Region-scoped OTLP gateway and Basic-auth credential derivation for Grafana Cloud. ## Verifying Two scripts check the pipeline end to end: `scripts/collector-deployment-verify.py` confirms the collector app is deployed and reports which provider exporters its configuration includes, and `scripts/dash0-telemetry-verify.py` emits real logs, traces, and span events so you can confirm they arrive in Dash0. Run both under `infisical run` so the provider credentials they inspect are present in the environment.