Skip to content

Competitive landscape: TL1A

An end-to-end run: three CLI commands, one script, and you have a landscape spreadsheet, a bullseye and the charts. Everything below is a real session against the live database, numbers included.

The commands are short enough to type yourself. They're written the way they are because this is the shape of work you hand to Claude Code: fetch to disk, look at what came back, then build.

TL1A bullseye

What you'll end up with

  • tl1a_landscape.xlsx — three sheets: assets, trials, deals
  • tl1a_bullseye.png — the chart above, most advanced at the centre
  • tl1a_charts.png — phase distribution, developers, trial starts per year

The prompt

In a terminal, in an empty directory:

Build me a TL1A competitive landscape. Pull the drugs, trials and deals from
the gosset CLI, check the pull for duplicates and off-thesis assets before
you build anything, then give me a spreadsheet, a bullseye and some charts.

The rest of this page is what that actually does, so you can run it yourself or adapt it to your own target.

1. Sign in

bash
pip install gosset
gosset auth

One browser sign-in. The key is stored, so every later command picks it up.

2. Pull the three datasets

bash
gosset drugs  --target TL1A --limit 100 > drugs.json
gosset trials --target TL1A --limit 50  > trials.json
gosset deals  --target TL1A --limit 50  > deals.json

--limit is a page size, and the cap differs per entity

Drugs and news cap at 100 a page; trials, deals and companies cap at 50. Ask for more and you get the cap, with a warning on stderr:

warning: asked for 100 but the server returned 50 of 67; the page size is
capped. Narrow the query with filters rather than a larger --limit.

TL1A has 67 trials and 58 deals, so both need a second page. Loop on --offset until a page comes back short:

bash
offset=0
while :; do
  gosset trials --target TL1A --limit 50 --offset $offset > "page_$offset.json"
  n=$(jq length "page_$offset.json")
  [ "$n" -lt 50 ] && break
  offset=$((offset + n))
done
jq -s add page_*.json > trials.json && rm page_*.json

Skip this and your landscape silently misses a third of the trials. The warning is the only thing standing between you and a confident wrong answer.

That gives 48 assets, 67 trials, 58 deals.

3. Look before you build

This is the step worth not skipping. Three things were wrong with the raw pull, and all three would have survived into the final chart. Each one is now a filed pipeline defect, so they are examples of a routine, not a list of quirks to memorise.

An asset appears twice.

bash
jq -r '.[] | select((.name // "") | test("duvakitug"; "i"))
       | "\(.drug_id)  \(.phase)  \(.name)"' drugs.json

The // "" is load-bearing. One TL1A asset has a null name, and test() against null aborts jq mid-stream, so the unguarded version prints the first match and then dies, which reads exactly like "there is only one".

678ac0ab38a5a10d0af7b50e  3            duvakitug
69c1087611bc031e789fe191  Preclinical  duvakitug (Teva Pharmaceutical)

Same molecule, two drug_ids, two different phases. Left alone it plots twice, once in the centre and once on the rim.

One Phase 3 asset isn't in your disease at all.

bash
jq -r '.[] | select(.phase == "3") | "\(.name)  ->  \(.lead_indication)"' drugs.json
afimkibart    ->  Ulcerative colitis (moderately to severely active)
tulisokibart  ->  Ulcerative Colitis (moderately to severely active)
duvakitug     ->  Ulcerative Colitis
FF-31501      ->  Meniscus injury indicated for meniscectomy

FF-31501 is an autologous mesenchymal stem cell therapy for knee meniscus tears. Its own description mentions no target at all, yet it is filed against ITGB1, PDGFRB and TL1A. Two of those are the giveaway: ITGB1 is CD29 and PDGFRB is CD140b, both standard MSC surface markers. Cell-phenotype markers have been recorded as drug targets, and TL1A came along with them.

So this is a bad row, not merely an out-of-scope one, and it costs you the number you care about most: 3 real Phase 3 competitors reported as 4, sitting at the centre of the bullseye where the eye lands first. It's filed as pipeline#606.

Scoping by disease removes it either way, and is worth doing regardless, because a target pull is not a disease pull:

bash
gosset drugs --target TL1A --disease "inflammatory bowel disease" --limit 100

One company is two companies.

bash
jq -r '.[].developers[]?' drugs.json | sort -u | grep -i aeglea
Aeglea BioTherapeutics
Aeglea Biotherapeutics

A casing difference splits one developer across two bars in any count you make. Fold case before grouping.

4. Build

Save as build_landscape.py and run it. It reads the three JSON files and writes the spreadsheet and both images.

python
import json
from collections import Counter

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

drugs  = json.load(open("drugs.json"))
trials = json.load(open("trials.json"))
deals  = json.load(open("deals.json"))

PHASE_ORDER = ["Preclinical", "1", "2", "3", "4", "Approved"]
BIG_PHARMA = {"Merck & Co.", "Roche", "Pfizer", "Sanofi", "AbbVie", "AbbVie Inc.",
              "Genentech", "Teva Pharmaceutical", "Eli Lilly", "Novartis",
              "AstraZeneca", "Bristol Myers Squibb", "GSK", "Takeda"}

def names(seq):
    """Deal parties and assets are objects: {name, category, country}."""
    return [x.get("name", "") if isinstance(x, dict) else str(x) for x in (seq or [])]

def first(x, default=""):
    return (x[0] if x else default) if isinstance(x, list) else (x or default)

# ---- sheet 1: the landscape
df = pd.DataFrame([{
    "Asset": d.get("name") or "(unnamed)",
    "Phase": d.get("phase") or "",
    "Lead developer": d.get("lead_developer") or first(d.get("developers")),
    "Originator": d.get("originator") or "",
    "Country": d.get("country") or "",
    "Modality": first(d.get("modalities")).split("→")[0].strip(),
    "Targets": ", ".join(d.get("targets") or []),
    "Lead indication": d.get("lead_indication") or "",
    "Trials": d.get("trial_count") or 0,
    "Combination target": "yes" if len(d.get("targets") or []) > 1 else "no",
    "Big pharma": "yes" if (d.get("lead_developer") in BIG_PHARMA
                            or any(x in BIG_PHARMA for x in d.get("developers") or [])) else "no",
} for d in drugs])
df["_ord"] = df["Phase"].apply(lambda p: PHASE_ORDER.index(p) if p in PHASE_ORDER else -1)
df = df.sort_values(["_ord", "Trials"], ascending=[False, False]).drop(columns="_ord")

tdf = pd.DataFrame([{
    "NCT": t.get("nct_id") or t.get("trial_id"), "Phase": t.get("phase"),
    "Status": t.get("status"), "Sponsor": t.get("lead_sponsor"),
    "Start": t.get("start_date"), "Has results": t.get("has_results"),
    "Title": (t.get("title") or "")[:120],
} for t in trials])

ddf = pd.DataFrame([{
    "Date": d.get("date"),
    "Type": ", ".join(d["deal_type"]) if isinstance(d.get("deal_type"), list) else d.get("deal_type"),
    "Buyers": ", ".join(names(d.get("buyers"))),
    "Sellers": ", ".join(names(d.get("sellers"))),
    "Assets": ", ".join(names(d.get("drugs"))),
    "Title": (d.get("title") or "")[:140],
} for d in deals]).sort_values("Date", ascending=False)

with pd.ExcelWriter("tl1a_landscape.xlsx", engine="openpyxl") as xl:
    df.to_excel(xl, sheet_name="Landscape", index=False)
    tdf.to_excel(xl, sheet_name="Trials", index=False)
    ddf.to_excel(xl, sheet_name="Deals", index=False)

# ---- the bullseye: rings by phase, most advanced at the centre
rings  = ["Preclinical", "1", "2", "3"]
labels = {"Preclinical": "Preclinical", "1": "Phase 1", "2": "Phase 2", "3": "Phase 3"}
radii  = {"3": 1.0, "2": 2.0, "1": 3.0, "Preclinical": 4.0}
inner  = {"3": 0.0, "2": 1.0, "1": 2.0, "Preclinical": 3.0}
shades = ["#f8fafc", "#eef2ff", "#e0e7ff", "#c7d2fe"]

fig, ax = plt.subplots(figsize=(11, 11))
for ph in rings:
    ax.add_patch(plt.Circle((0, 0), radii[ph], facecolor=shades[rings.index(ph)],
                            edgecolor="#cbd5e1", linewidth=1.2, zorder=1))

for ph in rings:
    members = df[df["Phase"] == ph]
    n = len(members)
    if not n:
        continue
    r_mid = (radii[ph] + inner[ph]) / 2
    for i, (_, row) in enumerate(members.iterrows()):
        ang = 2 * np.pi * i / n + (0.35 if ph != "3" else 0.0)
        r = r_mid + (0.20 * ((i % 3) - 1) if n > 6 else 0)
        x, y = r * np.cos(ang), r * np.sin(ang)
        big = row["Big pharma"] == "yes"
        ax.scatter([x], [y], s=190 if big else 110,
                   c="#7c3aed" if big else "#94a3b8",
                   edgecolors="white", linewidths=1.5, zorder=3)
        name = row["Asset"]
        ax.annotate(name[:21] + "…" if len(name) > 22 else name, (x, y),
                    fontsize=7.6, ha="center", xytext=(0, 11),
                    textcoords="offset points", zorder=4)

for ph in rings:
    ax.annotate(labels[ph], (0, radii[ph] - 0.13), fontsize=10, ha="center",
                color="#475569", weight="bold", zorder=5)

ax.set_xlim(-4.6, 4.6); ax.set_ylim(-4.6, 4.6)
ax.set_aspect("equal"); ax.axis("off")
ax.set_title("TL1A competitive landscape\nmost advanced at the centre; purple = large-cap developer",
             fontsize=14, weight="bold", pad=18)
plt.tight_layout(); plt.savefig("tl1a_bullseye.png", dpi=170, facecolor="white"); plt.close()

# ---- three supporting charts
fig, axes = plt.subplots(1, 3, figsize=(17, 5.2))

counts = [len(df[df["Phase"] == p]) for p in PHASE_ORDER]
axes[0].bar([labels.get(p, p) for p in PHASE_ORDER], counts, color="#7c3aed")
axes[0].set_title("Assets by phase", weight="bold"); axes[0].set_ylabel("assets")
axes[0].tick_params(axis="x", rotation=30)

devs = Counter()
for d in drugs:
    for x in set(d.get("developers") or []):
        devs[x] += 1                      # fold case here to merge Aeglea/AEGLEA
top = devs.most_common(8)[::-1]
axes[1].barh([t[0][:24] for t in top], [t[1] for t in top], color="#2563eb")
axes[1].set_title("Most TL1A assets, by developer", weight="bold")

years = Counter()
for t in trials:
    s = str(t.get("start_date") or "")
    if len(s) >= 4 and s[:4].isdigit():
        years[int(s[:4])] += 1
ys = sorted(years)
axes[2].plot(ys, [years[y] for y in ys], marker="o", color="#059669", linewidth=2)
axes[2].fill_between(ys, [years[y] for y in ys], color="#059669", alpha=0.13)
axes[2].set_title("TL1A trial starts per year", weight="bold")

for a in axes:
    a.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.savefig("tl1a_charts.png", dpi=160, facecolor="white")

5. What came out

The spreadsheet's first rows, sorted most advanced first:

AssetPhaseLead developerLead indicationTrials
afimkibart3RocheUlcerative colitis (mod-severe)19
tulisokibart3Merck & Co.Ulcerative colitis (mod-severe)15
duvakitug3SanofiUlcerative colitis7
FF-315013FUJIFILM Toyama ChemicalMeniscus injury1
SPY0022Aeglea BioTherapeuticsUlcerative colitis5
BCD-2612BIOCADCrohn's disease (mod-severe)4
SSGJ-62723S Guojian PharmaceuticalUlcerative colitis2
FG-M7012AbbVie Inc.Inflammatory bowel disease2
XmAb9422XencorUlcerative colitis1

TL1A charts

And the numbers worth reading off:

assets on target48
in the clinic19
Phase 34 as returned, 3 real (FF-31501 is a mis-filed cell therapy)
hitting TL1A plus another target19
with a large-cap developer11
trials67, of which 16 have posted results
deals58

Three things stand out, and each is a question the spreadsheet can answer next.

The field is early and crowded. 28 of 48 assets are preclinical against three Phase 3 programmes in IBD. Most of this competition has not been tested in patients.

Nearly every new entrant is a combination. 19 of 48 hit TL1A alongside something else, usually IL-23. The bet being placed is that TL1A alone isn't enough, which is a different bet from the one the Phase 3 assets are running.

Activity is accelerating. Trial starts run 4, 5, 10, 18, 21 across 2022 to 2026. The 2026 figure is already the largest in the series and the year isn't finished.

Adapting it

The pull is the only target-specific part. Swap it and everything downstream works unchanged:

bash
gosset drugs --target TROP2 --limit 100 > drugs.json
gosset drugs --disease "atopic dermatitis" --phase 3 --limit 100 > drugs.json
gosset drugs --target TL1A --disease "ulcerative colitis" --limit 100 > drugs.json

Run gosset schema drugs for the fields you can pull into the sheet, and Getting Started for the filters each entity accepts.

Data notes

Counts move as the database updates, so your run won't match these exactly. The duplicate and the casing collision above are real records on the day this was written; treat the checks in step 3 as the routine, not as a fix for two specific rows.

Gosset Documentation