> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tako.com/llms.txt
> Use this file to discover all available pages before exploring further.

# tako-graph-agent — resolve-example.ts

Install the official TypeScript SDK in your project with `npm install tako-sdk`.

Companion reference implementation for the [tako-graph-agent skill](/skills/tako-graph-agent). The first fenced code block below is the complete `resolve-example.ts` — save its contents (between the fences, exclusive) alongside `SKILL.md` at `.claude/skills/tako-graph-agent/resolve-example.ts`.

```typescript theme={null}
// Entity-first Tako graph resolution — reference implementation.
// Install the official SDK with npm install tako-sdk.
// Constants are starting points, not rules — tune to your latency/recall budget.
//
// Flow: label-boosted search per candidate name → top-N nodes per name, deduped
// across names in rank round-robin → one `related` call per node×filter fragment,
// merged per node → bounded no-q retry for nodes whose every fragment missed →
// hard cap on total calls.

import { Tako, ResponseError, type GraphNode as Node, type GraphRelation as RelationGroup, type GraphSearchLabelEnum } from "tako-sdk";

let client: Tako | undefined;
function tako(): Tako {
  if (!client) client = new Tako({ apiKey: process.env.TAKO_API_KEY! });
  return client;
}

const TOP_NODES_PER_NAME = 3; // the label boost ranks the right node up; 5+ mostly adds junk nodes
const RELATED_LIMIT = 8;      // items per node×fragment fetch (the ranked top is what you want)
const MAX_RELATED_CALLS = 24; // hard cap per question — full coverage of top nodes beats partial of all
const FULL_MENU_LIMIT = 40;   // bounded no-q fallback menu
const FULL_MENU_RETRIES = 2;  // nodes allowed the no-q fallback per question

export interface Lookup {
  entities: string[];      // 1-3 COMPLETELY DIFFERENT names for ONE subject ("Google", "Alphabet")
  label?: GraphSearchLabelEnum; // one of the 11 NER labels (see SKILL.md) — a ranking boost; omit when unsure
  metricFilters: string[]; // 2-5 short metric-NAME fragments ("revenue", "sales") — substring semantics
}

export interface Resolved { entity: Node; metrics: Node[] }

async function graphSearch(q: string, label?: GraphSearchLabelEnum): Promise<Node[]> {
  try {
    const response = await tako().graphSearch({ q, types: ["entity"], limit: TOP_NODES_PER_NAME, label });
    return response.results.slice(0, TOP_NODES_PER_NAME);
  } catch (error) {
    if (error instanceof ResponseError) return [];
    throw error;
  }
}

async function graphRelated(nodeId: string, q?: string, limit = RELATED_LIMIT): Promise<Node[]> {
  // No `label` here: the relation is already scoped to metrics, so label=METRIC would
  // boost every item equally — a no-op. label only helps a relation that MIXES labels.
  try {
    const response = await tako().graphRelated({ nodeId, relation: "metrics", limit, q });
    return response.relation?.items ?? [];
  } catch (error) {
    if (error instanceof ResponseError) return [];
    throw error;
  }
}

export async function resolveEntityFirst(lookup: Lookup): Promise<Resolved[]> {
  const names = dedupeCi(lookup.entities).slice(0, 3);
  const filters = dedupeCi(lookup.metricFilters).slice(0, 5);

  // 1. Search each candidate name ONCE. `label` only boosts ranking (it never
  //    excludes), so there is no filtered-0 and no unfiltered retry.
  const perName = await Promise.all(names.map((name) => graphSearch(name, lookup.label)));

  // 2. Dedupe by id in rank round-robin (rank-0 of every name before any
  //    rank-1) so the call cap below always covers each name's best hit first.
  const seen = new Set<string>();
  const ranked: Node[] = [];
  for (let rank = 0; perName.some((n) => n[rank]); rank++)
    for (const nodes of perName) {
      const n = nodes[rank];
      if (n && !seen.has(n.id)) { seen.add(n.id); ranked.push(n); }
    }

  // 3. One related call per node×fragment (q is a case-insensitive SUBSTRING
  //    match on metric names — several short fragments beat one perfect guess),
  //    truncated node-major at the hard cap.
  const pairs = ranked.flatMap((node) => filters.map((q) => ({ node, q }))).slice(0, MAX_RELATED_CALLS);
  const menus = new Map<string, Map<string, Node>>(); // nodeId → metricId → metric
  await Promise.all(pairs.map(async ({ node, q }) => {
    const menu = menus.get(node.id) ?? menus.set(node.id, new Map()).get(node.id)!;
    for (const m of await graphRelated(node.id, q)) menu.set(m.id ?? m.name.toLowerCase(), m);
  }));

  // 4. Safety net: a node whose EVERY fragment missed gets one bounded no-q
  //    fetch — the fragments were too specific; surface what actually exists.
  let retries = 0;
  for (const node of ranked) {
    const menu = menus.get(node.id);
    if (!menu || menu.size > 0 || retries >= FULL_MENU_RETRIES) continue;
    retries++;
    for (const m of await graphRelated(node.id, undefined, FULL_MENU_LIMIT)) menu.set(m.id ?? m.name.toLowerCase(), m);
  }

  return ranked
    .filter((n) => menus.has(n.id))
    .map((n) => ({ entity: n, metrics: [...menus.get(n.id)!.values()] }));
}

// ---- deterministic compose guards (run on LLM-composed /v3/search queries) ----

// A composed query survives ONLY if it cites a resolved metric verbatim (name
// or alias, case-insensitive) — the LLM picks and words, it cannot invent
// availability.
export function citesListedMetric(query: string, listed: Node[]): boolean {
  const q = query.toLowerCase();
  return listed.some((m) =>
    q.includes(m.name.toLowerCase()) || (m.aliases ?? []).some((a) => a && q.includes(a.toLowerCase())));
}

// /v3/search is most reliable single-entity: drop composed queries naming 2+
// DISTINCT resolved entities (textually overlapping names like "Tesla" ⊂
// "Tesla, Inc." count as one). A combined comparison query can work for famous
// pairs — but only as a bonus alongside per-entity queries, never instead.
export function namesMultipleEntities(query: string, resolved: Resolved[]): boolean {
  const q = query.toLowerCase();
  const named = [...new Set(resolved.map((r) => r.entity.name.toLowerCase()).filter((n) => q.includes(n)))];
  return named.filter((n) => !named.some((o) => o !== n && o.includes(n))).length >= 2;
}

// Mechanical fallback when compose keeps nothing: planner terms only,
// single-entity by construction. (Planner terms, not resolved graph names —
// keyword-matched graph names need the LLM's topical judgment.)
export function fallbackQueries(lookup: Lookup, cap = 3): string[] {
  const out: string[] = [];
  for (const f of lookup.metricFilters)
    for (const e of lookup.entities)
      if (!e.toLowerCase().includes(f.toLowerCase()) && !f.toLowerCase().includes(e.toLowerCase()))
        out.push(`${e} ${f}`);
  return [...new Set(out)].slice(0, cap);
}

function dedupeCi(xs: string[]): string[] {
  const seen = new Set<string>();
  return xs.map((s) => s.trim()).filter((s) => {
    const k = s.toLowerCase();
    if (!k || seen.has(k)) return false;
    seen.add(k);
    return true;
  });
}

// ---- overview/enumeration loop — "what's in a SET", not "what's a metric" ----
// A different question class from the entity-first loop above: cohorts
// ("Nvidia's competitors") and relationships ("who does X play for") live in
// the node's RELATIONS, not its metrics. One overview call replaces an
// LLM-recalled member list — the source of hallucinated comparisons — with a
// database read. See SKILL.md step 4.

export interface Cohort { label: string; key: string; members: Node[]; total: number; total_capped: boolean }

async function graphOverview(nodeId: string, q?: string): Promise<RelationGroup[]> {
  try {
    const response = await tako().graphRelated({ nodeId, q });
    return response.relations ?? [];
  } catch (error) {
    if (error instanceof ResponseError) return [];
    throw error;
  }
}

async function graphDrill(nodeId: string, key: string, limit = 100): Promise<Node[]> {
  try {
    const response = await tako().graphRelated({ nodeId, relation: key, limit });
    return response.relation?.items ?? [];
  } catch (error) {
    if (error instanceof ResponseError) return [];
    throw error;
  }
}

// Resolve a SET or RELATIONSHIP hanging off one anchor entity — "Nvidia's
// competitors", "all NBA teams", "the Magnificent Seven's members". `pick`
// selects ONE relation group from the real overview (deterministic label/kind
// match, or a small LLM choice over real headers) — this function never
// guesses a `rel:*` key, only fetches and filters what the server returned.
export async function enumerateCohort(
  anchorName: string,
  pick: (groups: RelationGroup[]) => RelationGroup | undefined,
  label?: GraphSearchLabelEnum,
): Promise<Cohort | null> {
  // 1. Resolve the anchor exactly like the entity-first loop — one label-boosted
  //    search (the boost never excludes, so there is nothing to retry).
  const anchors = await graphSearch(anchorName, label);
  const anchor = anchors[0];
  if (!anchor) return null;

  // 2. ONE overview call — every non-empty relation group on the node.
  //    Some nodes legitimately have none at all.
  const groups = await graphOverview(anchor.id);
  if (groups.length === 0) return null; // no edges — fall back to /v1/answer or /v3/search

  // 3. Drop groups that can never be a cohort/comparison set before handing
  //    the menu to `pick`: `sibling` groups enumerate the whole CLASS
  //    namespace ("Other Companies"), not this node's peers.
  const candidates = groups.filter((g) => g.kind !== "sibling");
  const chosen = pick(candidates);
  if (!chosen) return null;

  // 4. Reciprocal named edges duplicate membership (`competes_with` /
  //    `competitors_of` carry the same set) — dedupe defensively by id.
  const dedupe = (items: Node[]) => [...new Map(items.map((n) => [n.id ?? n.name, n])).values()];

  // 5. Inline items already cover small groups; drill only when the real
  //    roster is bigger than what came inline with the overview.
  const members =
    chosen.items.length >= chosen.total
      ? dedupe(chosen.items)
      : dedupe(await graphDrill(anchor.id, chosen.key, Math.min(chosen.total, 100)));

  return { label: chosen.label, key: chosen.key, members, total: chosen.total, total_capped: chosen.total_capped ?? false };
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.