Resolve free-text agency names¶
You have a column of agency strings — "Department of Energy", "DOE", "Energy", "Lab @ Argonne" — and you need canonical Tango identifiers (a CGAC code, an FPDS code, an Organization UUID) before you can join, filter, or load. This guide shows you the smallest loop that gets you there.
The 1-line answer¶
curl -X POST https://tango.makegov.com/api/resolve/ \
-H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Department of Energy", "target_type": "organization"}'
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
result = client.resolve(name="Department of Energy", target_type="organization")
for c in result.candidates:
print(c.identifier, c.display_name, c.match_tier)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const result = await client.resolve({
name: "Department of Energy",
target_type: "organization",
});
for (const c of result.candidates) {
console.log(c.identifier, c.display_name, c.match_tier);
}
target_type is "organization" for any government org (department, agency, sub-agency, office). "entity" is for vendors/recipients.
What you get back¶
{
"candidates": [
{
"identifier": "362361bf-4dd3-5254-85a3-4649884108d6",
"display_name": "ENERGY, DEPARTMENT OF",
"match_tier": "high"
}
],
"count": 1
}
identifier— the UUIDkeyof the matchingOrganization. Use it directly against/api/organizations/{identifier}/. To use in an agency filter on list endpoints, prefer thefh_keyor CGAC code (obtainable by hydrating the org record first, or by using the org'sshort_name/abbreviation directly in the filter). See Agency search pattern guide for full filter semantics.display_name— the canonical name on file.match_tier—"high"/"medium"/"low". Free tier callers do not get this field; they see onlyidentifieranddisplay_nameand at most 3 candidates. Pro+ gets up to 5 candidates withmatch_tier. See the resolve reference for the full contract.
The endpoint does not return hierarchy context (parent department, level, etc.) inline. If you need that, hydrate via /api/organizations/{identifier}/ once you have a winner.
Free-text only
/api/resolve/ is for free-text strings. If your input is already a numeric code (3-digit CGAC, 4-digit FPDS), you don't need to resolve — skip to Numeric codes below.
Boost quality with context¶
The resolver weights signals beyond the bare name. If your spreadsheet has city, state, or any descriptive context, pass it:
curl -X POST https://tango.makegov.com/api/resolve/ \
-H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Lab @ Argonne",
"target_type": "organization",
"state": "IL",
"context": "national laboratory, basic science research, DOE"
}'
result = client.resolve(
name="Lab @ Argonne",
target_type="organization",
state="IL",
context="national laboratory, basic science research, DOE",
)
const result = await client.resolve({
name: "Lab @ Argonne",
target_type: "organization",
state: "IL",
context: "national laboratory, basic science research, DOE",
});
Useful context fields: industry references (NAICS/PSC), parent department name, location signals, mission text from the source row. More context generally narrows the candidate set.
Handling low-confidence matches¶
Treat match_tier as a heuristic, not a verdict. Empirically the tiers are noisy in both directions: a high-tier hit on an abbreviation collision can still be the wrong org, and a low-tier hit on a partial string ("Environmental Protection" → EPA at low) can still be the right one.
| Tier | Reasonable default | Caveat |
|---|---|---|
high | Promising. Safe enough for human-reviewed surfaces (search dropdowns, autocomplete). | A high tier can still be wrong on abbreviation collisions — don't auto-join into a downstream system without spot-checking. |
medium | Inspect candidates[1:] before accepting. | Often the second candidate is what you actually wanted. |
low | Treat as a suggestion. | Sometimes correct (e.g., a clipped partial name) — don't discard before reviewing. |
count: 0 | Queue for human review. | No match at all — the string didn't hit anything. |
On the free tier you don't get match_tier, so use ordinal position as a weak proxy (first candidate is best) and lean harder on validating downstream — or upgrade if this is core to your workflow.
Don't assume the top hit is right
The resolver returns ranked candidates, not a single answer. For ambiguous strings ("Energy" could be the Department, an EERE office, an EIA program) the second candidate is sometimes what you wanted. If your input might be ambiguous, log all candidates, not just [0].
Anti-pattern: tiered auto-accept into automated joins
Wiring if tier == "high": auto_accept() straight into an ETL pipeline that writes to a downstream system is a great way to ship silently-wrong joins. Strings like "Dept of Energy" (no period, abbreviated "Dept") have been observed to resolve to unrelated logistics orgs at high tier in some snapshots — the resolver is doing its best on noisy input, but it can't tell you when it's wrong. Rule of thumb: if a wrong answer would corrupt a downstream join, send anything below explicit, reviewed confidence through a human-in-the-loop step regardless of tier.
Numeric codes: skip the resolver¶
If your input is already a federal code, the resolve endpoint is the wrong tool — it's tuned for free-text names. Use the Organizations API or any list endpoint's agency filter, which understand numeric codes directly:
| Input shape | Means | Resolves to |
|---|---|---|
3-digit (069, 075, 089) | CGAC code | L1 department |
4-digit (2100, 7530) | FPDS sub-tier code | L2 agency under the matching department |
8-9 digit (100011980) | fh_key | Exact Organization row |
Letters + digits (15JCRM) | Office / AAC code | Office-level org |
# CGAC — direct list filter
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/api/organizations/?cgac=089"
# FPDS code — multi-stage search picks it up via aliases
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/api/organizations/?search=2100"
# fh_key — direct path lookup (DOE fh_key = 100011980)
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/api/organizations/100011980/"
# CGAC — direct list filter
resp = client.list_organizations(cgac="089")
# FPDS code — multi-stage search picks it up via aliases
resp = client.list_organizations(search="2100")
# fh_key — direct path lookup
org = client.get_organization("100011980")
// CGAC — direct list filter
let resp = await client.listOrganizations({ cgac: "089" });
// FPDS code — multi-stage search picks it up via aliases
resp = await client.listOrganizations({ search: "2100" });
// fh_key — direct path lookup
const org = await client.getOrganization("100011980");
For filter-time use ("give me contracts under DOE"), the agency filter on every list endpoint accepts any of these forms — see the Agency search pattern guide for full semantics.
Batch loop: clean a CSV¶
The endpoint is per-call, not bulk. To clean a CSV column, loop with light concurrency.
Illustrative, not production-ready
The scripts below are teaching examples — sequential, no de-duplication, no retries, no concurrency. For real 1000-row ETL jobs, dedupe inputs first ("DOE" appears 400 times → resolve once, fan out), wrap calls in retry-with-backoff, and pace yourself off the SDK's exposed rate-limit headers: client.rate_limit_info (Python) / client.rateLimitInfo (Node). Both expose remaining, reset, and retryAfter after each request.
import csv
import os
import time
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
def resolve_agency(name: str, *, state: str | None = None, context: str | None = None) -> dict:
"""Return the top candidate plus the full ranked list for review."""
result = client.resolve(
name=name,
target_type="organization",
state=state,
context=context,
)
candidates = result.candidates
return {
"input": name,
"match_count": result.count,
"top_identifier": candidates[0].identifier if candidates else None,
"top_display_name": candidates[0].display_name if candidates else None,
"top_tier": candidates[0].match_tier if candidates else None, # None on free tier
"all_candidates": candidates,
}
with open("agencies_in.csv") as fin, open("agencies_resolved.csv", "w") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(
fout,
fieldnames=["input", "identifier", "display_name", "tier", "needs_review"],
)
writer.writeheader()
for row in reader:
result = resolve_agency(row["agency"], state=row.get("state"))
tier = result["top_tier"]
writer.writerow(
{
"input": result["input"],
"identifier": result["top_identifier"] or "",
"display_name": result["top_display_name"] or "",
"tier": tier or "",
"needs_review": tier in (None, "low") or result["match_count"] == 0,
}
)
time.sleep(0.05) # be polite; standard rate limits apply
import { TangoClient, type ResolveCandidate } from "@makegov/tango-node";
import { createReadStream, createWriteStream } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { parse } from "csv-parse";
import { stringify } from "csv-stringify";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
async function resolveAgency(
name: string,
opts: { state?: string; context?: string } = {},
) {
const result = await client.resolve({
name,
target_type: "organization",
state: opts.state,
context: opts.context,
});
const candidates: ResolveCandidate[] = result.candidates ?? [];
const top = candidates[0];
return {
input: name,
matchCount: result.count ?? 0,
topIdentifier: top?.identifier ?? null,
topDisplayName: top?.display_name ?? null,
topTier: top?.match_tier ?? null, // null on free tier
allCandidates: candidates,
};
}
const parser = createReadStream("agencies_in.csv").pipe(
parse({ columns: true }),
);
const writer = stringify({
header: true,
columns: ["input", "identifier", "display_name", "tier", "needs_review"],
});
writer.pipe(createWriteStream("agencies_resolved.csv"));
for await (const row of parser) {
const result = await resolveAgency(row.agency, { state: row.state });
const tier = result.topTier;
writer.write({
input: result.input,
identifier: result.topIdentifier ?? "",
display_name: result.topDisplayName ?? "",
tier: tier ?? "",
needs_review:
tier === null || tier === "low" || result.matchCount === 0,
});
await sleep(50); // be polite; standard rate limits apply
}
writer.end();
Either script writes identifier (the UUID key returned by the resolver) into the output column. That UUID is Organization.key — it's what /api/organizations/{identifier}/ accepts. It is not an fh_key (the 8–9 digit numeric Federal Hierarchy code); if you need the fh_key for downstream filter use, hydrate the org record first.
Pitfalls¶
target_typeis"organization", not"agency". The endpoint has no"agency"choice; all government orgs (department, agency, sub-agency, office) live under"organization".- GET won't work. This endpoint is POST-only with a JSON body. Drop the
?q=pattern you may have seen in older snippets. - "Department of X" vs codes. The resolver handles both well in isolation, but mixed input shapes ("DOE",
089, "Energy") in one column means you should branch: route numeric-looking values through/api/organizations/?search=and free text through/api/resolve/. - Sub-offices bubble up. A string like
"NNSA Office of Defense Programs"may resolve to NNSA (L2) rather than the specific program office (L3). Pass the office name incontextto nudge the resolver, or hydrate the result and walk down via/api/organizations/?parent_fh_key=.... - Deprecated / reorganized agencies. Tango preserves demoted Federal Hierarchy records by
fh_keyso historical IDs still resolve, but thedisplay_nameyou get back may be the current canonical name, not the historical one. If you're matching contracts from 2010, expect the modern descendant org back — that's intentional. See Federal agency hierarchy for the provenance story. - Empty
candidatesarray, HTTP 200. No match is not an error. Always checkcountbefore indexingcandidates[0]. identifieris a UUID, not anfh_key. The resolver returns the org's UUIDkey. If your downstream system expects the 8–9 digitfh_key, hydrate the org record first — don't pipe the UUID into an?fh_key=...filter.
See also¶
- Resolve endpoint reference — full request/response contract, tier limits, error codes.
- Organizations API — direct lookups by
fh_key, CGAC filter, search. - Federal agency hierarchy — what CGAC, FPDS, L1/L2/L3 mean and where the data comes from.
- Agency search pattern — how the agency filter on every list endpoint resolves the same shapes of input at query time.