Gil Allouche
← All writing
TutorialAgentic GTM

Build a GTM strategy generator in one Python file

A ranked segment list with a falsifiable claim, a cheapest test and a kill signal for each one, written to gtm_plan.md by a script you run yourself.

November 13, 2026·Gil Allouche·13 min read
A build-along. Every step was run start to finish; commands and outputs are what the machine actually printed.

What you end up with

gtm.py is about 140 lines. It reads your ICP hypotheses out of icp.json, sends each candidate segment to Claude Sonnet 4.5 in its own isolated call, and writes gtm_plan.md: segments ranked by a weighted score your code computes, each carrying one falsifiable positioning claim, the cheapest test that could break it, and the observation that kills the segment. Twenty minutes. Most of it typing.

You need Python 3.10 or newer — python3 --version — and an Anthropic API key from console.anthropic.com/settings/keys on an account that has credit on it. Then a terminal, and ten minutes of honesty about what evidence you actually have.

That last one is the prerequisite people skip. The script scores on the evidence you supply. Supply none and it will tell you, in writing, that you have none — which is the useful output.

The build

  1. Make the project and a virtualenv: mkdir gtm && cd gtm && python3 -m venv .venv && source .venv/bin/activate. Your prompt now starts with (.venv).
  2. Install the SDK: pip install anthropic. The last line reads Successfully installed anthropic-<version> ... plus httpx, pydantic and friends.
  3. Create a key at console.anthropic.com/settings/keys and export it: export ANTHROPIC_API_KEY=sk-ant-.... Verify without printing the whole thing: echo ${ANTHROPIC_API_KEY:0:7} returns sk-ant-.
  4. Write icp.json — product, the evidence you actually have, and your candidate segments. Template is in the next section. Validate it: python -c "import json;print(len(json.load(open('icp.json'))['segments']))" prints your segment count.
  5. Save gtm.py from the section below into the same directory.
  6. Run it: python gtm.py icp.json. You get one assessing: ... line per segment on stderr, then a token count and a cost figure.
  7. Read the ranking: head -25 gtm_plan.md. You get a markdown table, highest score first, with the cheapest test in the right-hand column.
  8. Delete the bottom third of that table. Book calls against the top two tests this week. The file is an input to that, not a deliverable.

icp.json: the input that decides everything

Three keys. product is one paragraph. evidence is a list of things that happened — not things you believe. segments is your list of guesses.

{
  "product": "A scheduling API for field-service software vendors. Their customers dispatch technicians; we handle route optimisation, time-window promises and reschedule logic so the vendor does not have to build it.",
  "evidence": [
    "4 inbound demo requests in Q1 from vendors with 10-40 engineers, all mentioning reschedule logic by name",
    "2 of those 4 had already built a v1 internally and abandoned it",
    "1 signed pilot at $1,400/month, deployed to production in 6 weeks",
    "0 inbound from enterprise field-service suites",
    "Pricing page gets 300 sessions/month, 2% hit the docs link"
  ],
  "segments": [
    {
      "name": "Vertical SaaS vendors, 10-40 engineers, serving HVAC and plumbing",
      "notes": "Where the pilot came from. Buyer is usually VP Eng or a technical founder. Contact pattern: support@vendor.example, then a Slack Connect channel."
    },
    {
      "name": "Enterprise field-service suites, 500+ employees",
      "notes": "No inbound. Procurement, security review, 2 years of roadmap committed. Listed because a board member keeps asking."
    },
    {
      "name": "Staffing marketplaces placing hourly shift workers",
      "notes": "Adjacent problem shape. One cold conversation, no follow-up. Domain pattern: ops@marketplace.example."
    }
  ]
}

The evidence list does the most work and takes the most discipline. A usable entry carries a count, a date range or a named source: "4 inbound demo requests in Q1", "1 signed pilot at $1,400/month". An unusable one is a conclusion wearing an observation's clothes — "SMB vendors clearly feel this pain" is a belief, and the model will either score on it or flag it, neither of which helps you. Zeroes belong in the list too. "0 inbound from enterprise field-service suites" is the most load-bearing line in the example file, because it is what pushes the enterprise segment to the bottom of the ranking instead of leaving it unscored.

Start with eight to fifteen segments. Fewer than five and you are confirming a decision you already made. More than twenty and the ranking is noise, because by segment twenty-one you are inventing segments to fill the file.

I took Metadata.io from $0 to $15M ARR and raised $50M doing it. The hard part of go-to-market was never generating segments. It was subtraction — and subtraction needs a score you did not negotiate with yourself after seeing the result. Which is why the model is not allowed anywhere near the ranking.

Why the model never ranks

The model writes judgement: a claim, four scores from 1 to 5, a reason per score, a test, a kill signal. Python applies the weights, does the multiplication, and sorts. The model sees one segment at a time and is told explicitly not to compare.

Model writes judgement, Python does the arithmetic and the rankingicp.jsonproduct, evidenceClaude Sonnet 4.5one segment per callclaim, scores 1-5test, kill signalboundaryjudgementarithmeticgtm.pyapplies weightssorts descendingcaps retries at 3gtm_plan.mdranked, with tests

Two reasons for the split. A ranking produced inside one long prompt is not reproducible — change the segment order in the input and the top three move, because the model has seen the others. One call per segment, no comparison, deterministic arithmetic outside: run it twice, get the same order.

The second reason is that the weights are now a thing you can argue about in a meeting. reachability: 0.30 is a decision with your name on it. "The AI said this segment was best" is not.

The weights in the file are a starting point, not a recommendation. Change them. They are four lines at the top.

The score itself is deliberately boring. Each dimension comes back as an integer from 1 to 5, gets multiplied by its weight, and the weighted sum is multiplied by 20 so the result lands on a 0-100 scale: a segment scoring 5 everywhere reaches 100, one scoring 1 everywhere reaches 20. There is no floor at zero, which is intentional. A segment you bothered to write down is never worth nothing — it is worth the cost of the single call that kills it, and the bottom of the table is where you find out which calls those are.

gtm.py

#!/usr/bin/env python3
"""gtm.py - rank ICP segments and attach a falsifiable test to each one.

Usage:
    export ANTHROPIC_API_KEY=sk-ant-...
    python gtm.py icp.json

Writes gtm_plan.md in the current directory.
"""

import json
import sys
import time

import anthropic

MODEL = "claude-sonnet-4-5"      # alias; pins to the latest Sonnet 4.5 snapshot
MAX_TOKENS = 1500
MAX_ATTEMPTS = 3                 # hard cap on retries per segment
INPUT_USD_PER_MTOK = 3.00        # list price, see anthropic.com/pricing
OUTPUT_USD_PER_MTOK = 15.00

WEIGHTS = {
    "reachability": 0.30,
    "pain_evidence": 0.30,
    "budget_authority": 0.25,
    "cycle_speed": 0.15,
}

SYSTEM = """You assess one candidate go-to-market segment at a time.

Rules:
- Score only on the evidence supplied. If a dimension has no supporting
  evidence, score it 1 or 2 and the reason must begin "no evidence supplied".
- positioning_claim must be falsifiable: one sentence that a single
  30-minute customer call could prove wrong.
- cheapest_test is the lowest-cost action that produces that proof. Prefer
  calls, a landing page, or manual outbound. Never propose anything that
  requires engineering work.
- kill_signal is the specific observation that would make you drop the
  segment entirely.
- Do not rank. Do not compare to other segments. You see one at a time.
"""

TOOL = {
    "name": "record_assessment",
    "description": "Record the assessment for one candidate segment.",
    "input_schema": {
        "type": "object",
        "properties": {
            "positioning_claim": {"type": "string"},
            "reachability": {"type": "integer", "minimum": 1, "maximum": 5},
            "pain_evidence": {"type": "integer", "minimum": 1, "maximum": 5},
            "budget_authority": {"type": "integer", "minimum": 1, "maximum": 5},
            "cycle_speed": {"type": "integer", "minimum": 1, "maximum": 5},
            "reasons": {
                "type": "object",
                "properties": {
                    "reachability": {"type": "string"},
                    "pain_evidence": {"type": "string"},
                    "budget_authority": {"type": "string"},
                    "cycle_speed": {"type": "string"},
                },
                "required": list(WEIGHTS.keys()),
            },
            "cheapest_test": {"type": "string"},
            "kill_signal": {"type": "string"},
        },
        "required": [
            "positioning_claim", "reachability", "pain_evidence",
            "budget_authority", "cycle_speed", "reasons",
            "cheapest_test", "kill_signal",
        ],
    },
}


def assess(client, product, evidence, segment):
    """One segment, one call. Returns (assessment dict, usage object)."""
    prompt = (
        f"PRODUCT\n{product}\n\n"
        "EVIDENCE WE ACTUALLY HAVE\n- " + "\n- ".join(evidence) + "\n\n"
        f"CANDIDATE SEGMENT\n{segment['name']}\n"
        f"{segment.get('notes', '(no notes supplied)')}"
    )

    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            resp = client.messages.create(
                model=MODEL,
                max_tokens=MAX_TOKENS,
                system=SYSTEM,
                tools=[TOOL],
                tool_choice={"type": "tool", "name": "record_assessment"},
                messages=[{"role": "user", "content": prompt}],
            )
        except (anthropic.RateLimitError,
                anthropic.InternalServerError,
                anthropic.APIConnectionError) as err:
            if attempt == MAX_ATTEMPTS:
                raise
            wait = 2 ** attempt
            print(f"  {type(err).__name__}: attempt {attempt}/{MAX_ATTEMPTS}, "
                  f"sleeping {wait}s", file=sys.stderr)
            time.sleep(wait)
            continue

        if resp.stop_reason == "max_tokens":
            raise RuntimeError(
                f"tool call truncated for '{segment['name']}' - raise MAX_TOKENS")

        for block in resp.content:
            if block.type == "tool_use":
                return block.input, resp.usage

        raise RuntimeError(f"no tool_use block returned for '{segment['name']}'")

    raise RuntimeError("retry loop exited without a result")


def weighted_score(a):
    """0-100. Arithmetic lives here, not in the model."""
    return round(20 * sum(WEIGHTS[k] * a[k] for k in WEIGHTS), 1)


def main():
    if len(sys.argv) != 2:
        sys.exit("usage: python gtm.py icp.json")

    with open(sys.argv[1]) as fh:
        spec = json.load(fh)

    client = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the env
    rows, in_tok, out_tok = [], 0, 0

    for segment in spec["segments"]:
        print(f"assessing: {segment['name']}", file=sys.stderr)
        assessment, usage = assess(
            client, spec["product"], spec["evidence"], segment)
        assessment["name"] = segment["name"]
        assessment["score"] = weighted_score(assessment)
        rows.append(assessment)
        in_tok += usage.input_tokens
        out_tok += usage.output_tokens

    rows.sort(key=lambda r: r["score"], reverse=True)

    with open("gtm_plan.md", "w") as fh:
        fh.write(f"# GTM plan - {len(rows)} segments assessed\n\n")
        fh.write("| # | Segment | Score | Cheapest test |\n|---|---|---|---|\n")
        for i, r in enumerate(rows, 1):
            fh.write(f"| {i} | {r['name']} | {r['score']} | {r['cheapest_test']} |\n")
        for i, r in enumerate(rows, 1):
            fh.write(f"\n## {i}. {r['name']} - {r['score']}/100\n\n")
            fh.write(f"**Claim.** {r['positioning_claim']}\n\n")
            for k in WEIGHTS:
                fh.write(f"- **{k}** {r[k]}/5 (weight {WEIGHTS[k]}) - {r['reasons'][k]}\n")
            fh.write(f"\n**Cheapest test.** {r['cheapest_test']}\n\n")
            fh.write(f"**Kill signal.** {r['kill_signal']}\n")

    cost = (in_tok / 1e6 * INPUT_USD_PER_MTOK
            + out_tok / 1e6 * OUTPUT_USD_PER_MTOK)
    print(f"\n{len(rows)} segments -> gtm_plan.md", file=sys.stderr)
    print(f"tokens: {in_tok} in / {out_tok} out", file=sys.stderr)
    print(f"cost at list price: ${cost:.4f}", file=sys.stderr)


if __name__ == "__main__":
    main()

tool_choice={"type": "tool", "name": "record_assessment"} forces the model to answer through the schema instead of writing prose. That is what makes the output parseable every time rather than most of the time.

What you should see

Progress goes to stderr, so you can pipe stdout without losing it:

$ python gtm.py icp.json
assessing: Vertical SaaS vendors, 10-40 engineers, serving HVAC and plumbing
assessing: Enterprise field-service suites, 500+ employees
assessing: Staffing marketplaces placing hourly shift workers

3 segments -> gtm_plan.md
tokens: 2847 in / 1932 out
cost at list price: $0.0375

Your token counts will differ with the size of your evidence list. The point is that the script prints them, so you are never guessing at the bill.

Then head -25 gtm_plan.md:

# GTM plan - 3 segments assessed

| # | Segment | Score | Cheapest test |
|---|---|---|---|
| 1 | Vertical SaaS vendors, 10-40 engineers... | 84.0 | Call the 2 vendors who abandoned...
| 2 | Staffing marketplaces placing hourly... | 46.0 | Cold outbound to 20 ops leads...
| 3 | Enterprise field-service suites, 500+... | 29.0 | None worth running this quarter...

The interesting part is below the table. Each segment gets a **Kill signal.** line. If the model cannot produce a specific one, your segment definition is too vague to test — that is a signal about your thinking, not a bug in the script.

Check one thing before you trust the ranking. Open the section for your highest-scoring segment and read the pain_evidence reason. If it starts with "no evidence supplied" and still scored 4, the model inferred rather than read, and you should re-run after adding the evidence you were relying on implicitly.

What this costs to run

Claude Sonnet 4.5 is listed at $3 per million input tokens and $15 per million output tokens. A twelve-segment pass at roughly 1,200 input and 700 output tokens per call works out to 14,400 input tokens ($0.043) and 8,400 output tokens ($0.126). Call it 17 cents a run. Your numbers will differ; the script prints them.

LeverEffect on costTrade
Longer evidence listInput tokens rise on every callBetter reasons, less inference
More segmentsLinear increase in callsRanking gets noisier past ~20
MAX_TOKENS higherNo cost unless output growsAvoids truncated tool calls
Retries on 429/5xxUp to 3x a single callCapped by MAX_ATTEMPTS

That cap matters more than the cents. Unbounded retries and unbounded loops are the documented failure class in agent systems — OWASP files it as LLM10:2025 Unbounded Consumption. The fix is always the same shape: a counter enforced in code, outside the model. I wrote about the general pattern in an autonomous agent is a while loop with a step cap.

When it doesn't work

anthropic.AuthenticationError: invalid x-api-key

The key is not in the environment of the shell you are running in. Opening a new terminal tab drops the export. Check with echo ${ANTHROPIC_API_KEY:0:7} — it should print sk-ant-. An empty line means re-export it, or put it in a .env and load it. Do not paste the key into gtm.py; you will commit it.

anthropic.BadRequestError: ... credit balance is too low

The key is valid, the account has no credit. Add credit under Billing in the Console. A free key will authenticate and then fail on the first messages.create, which is why this looks like a code bug and isn't.

json.decoder.JSONDecodeError or KeyError: 'segments'

The input file is malformed, or a top-level key is missing. Run the validation command from step 4 before the script: python -c "import json;print(len(json.load(open('icp.json'))['segments']))". A trailing comma after the last segment is the usual cause — JSON does not allow one, unlike Python. If the count prints and the script still fails, check that every segment object has a name. notes is optional and falls back to (no notes supplied), but name is read directly and will raise.

RuntimeError: tool call truncated for '<segment>' - raise MAX_TOKENS

The model ran out of output budget mid-tool-call, so there was no complete tool_use block to parse. The script catches it by checking resp.stop_reason == "max_tokens" before touching resp.content — otherwise you get a confusing parse error three lines later. Raise MAX_TOKENS to 2500, or shorten the reasons requirement in the prompt. Long segment notes push output length up, because the model mirrors the detail level it is given.

anthropic.RateLimitError on a large file

You will see the retry lines on stderr: RateLimitError: attempt 1/3, sleeping 2s. The backoff is 2 ** attempt, so 2s then 4s, then it raises. If you hit it repeatedly with fifteen segments, you are above your tier's requests-per-minute limit. Add a time.sleep(1) between segments in the main loop. Raising MAX_ATTEMPTS just hides the problem behind a longer wall-clock time.

Where I would not point this

I would not let this script send anything. It writes a file. The step after it — contacting the twenty ops leads in the "cheapest test" column — is where people bolt on an outbound agent and discover they have automated the part that needed a human and kept doing the part that didn't. The research half of sales takes to automation well. The conversation half does not, which is the argument I made in AI changed the research half of sales, not the closing half.

And I would not run it once. A GTM strategy that is a document is dead on arrival; one that is a script is something you re-run after every ten customer calls, with the new evidence pasted into icp.json, and watch the ranking move. When segment #1 drops to #4 because the kill signal fired, you learned something. When it doesn't move at all for three months, you have stopped collecting evidence.

Sources

  1. Anthropic API — Getting started — Messages API call shape, SDK install, authentication header
  2. Anthropic Console — API keys — Where the reader gets ANTHROPIC_API_KEY
  3. Anthropic pricing — Claude Sonnet 4.5 list price of $3 per million input tokens and $15 per million output tokens
  4. Claude models overview — Model IDs and aliases, including claude-sonnet-4-5
  5. Tool use with Claude — tool_choice forcing a specific tool, input_schema, tool_use content blocks
  6. anthropic-sdk-python — Exception classes (RateLimitError, InternalServerError, APIConnectionError) and usage object fields

Related