TeamAPI latest
On this page
  1. Paging and caching
  2. Semantic search
  3. Exposing it beyond localhost
  4. Staying current
  5. Scale

REST API

teamapi serve-api examples/acme-org --port 3000 spins up a live REST API over ACME Org. Open /docs for a Swagger UI with a "Try it out" button on every endpoint, or /docs/json for the raw OpenAPI spec.

Endpoint Returns
GET /teams, /teams/:id Team list / a single team
GET /teams/:id/interactions, /teams/:id/dependencies, /teams/:id/roles Team detail slices
GET /services, /services/:name Service catalog
GET /search?q= Free-text search across teams, services, roles, members
GET /graph The full resolved org graph
GET /diagrams/topology, /diagrams/hierarchy/:teamId, /diagrams/org-hierarchy Diagram data
GET /context-map DDD context map
GET /cognitive-load, /cognitive-load/:teamId Cognitive load assessments
GET /gaps Accountability holes between teams
GET /policy, /topology Declared-policy outcomes and Team Topologies design smells
GET /<domain>, /teams/:id/<domain>, /teams/:id/<domain>/:resourceId Any AI-native section: /agents, /memory, /specifications, /steering, /prompts, /playbooks, /policies, /knowledge-base, /workflows, /sessions
POST /teams/:id/prompts/:promptId/render Fill a prompt's {{variable}} placeholders
POST /context Context bundle for a stated goal
GET /knowledge-graph, /knowledge-graph/:nodeId/traverse Knowledge graph traversal
GET /health Health check

Example: curl http://127.0.0.1:3000/cognitive-load — note supervision, the optional load of supervising a team's AI agents. It stays out of total (whose thresholds are calibrated against the three Team Topologies types), but it's one of the label's independent triggers, on the same thresholds as extraneous — a team drowning in agent review shouldn't be able to report "sustainable" on the strength of three modest other scores. A team that hasn't scored it is unaffected.

[
  {
    "teamId": "platform-payments",
    "total": 18,
    "label": "elevated",
    "assessment": {
      "intrinsic": 7,
      "extraneous": 5,
      "germane": 6,
      "supervision": 6,
      "notes": "PCI compliance scope adds real intrinsic complexity; onboarding docs need work. Supervising the agent fleet costs about a day a week across the team and appears on nobody's role description."
    }
  },
  {
    "teamId": "stream-checkout",
    "total": 18,
    "label": "overloaded",
    "assessment": {
      "intrinsic": 6,
      "extraneous": 8,
      "germane": 4,
      "notes": "High extraneous load from juggling three upstream integrations (payments, onboarding, fulfillment) with inconsistent contracts; a strong candidate for an anticorruption layer."
    }
  },
  {
    "teamId": "stream-onboarding",
    "total": 11,
    "label": "sustainable",
    "assessment": {
      "intrinsic": 4,
      "extraneous": 2,
      "germane": 5,
      "notes": "Well-bounded domain, low incidental complexity."
    }
  }
]

Paging and caching#

Every collection route takes limit and offset, and answers with X-Total-Count plus an RFC 8288 Link header carrying first/prev/next/last:

curl -sD- 'http://127.0.0.1:3000/teams?limit=2&offset=2' -o /dev/null | grep -i '^link\|^x-total'
x-total-count: 4
link: </teams?limit=2&offset=0>; rel="first", </teams?limit=2&offset=0>; rel="prev", </teams?limit=2&offset=2>; rel="last"

The body stays an array, with pagination data in headers. An { items, total, next } envelope would have broken every existing consumer when pagination shipped, including the dashboard, generators, and user scripts, while adding information the headers already carry. There is no default page size. A caller that previously read all 400 teams from GET /teams must not begin reading only 100 without any indication. Pagination starts only when the caller requests it.

Every GET also carries a strong ETag and honours If-None-Match:

curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "<etag>"' http://127.0.0.1:3000/graph
# 304

The validator is derived from the response body, not from the graph's resolvedAt. That timestamp changes on every reload — every --watch trigger, every POST /reload, every SIGHUP — including the common case where a document was touched and nothing a given endpoint returns actually changed. Hashing the body means the graph can be re-resolved a hundred times and /teams keeps the same ETag until /teams genuinely differs.

GET /search matches substrings, which answers "checkout-api" perfectly and "who owns the thing that charges cards" not at all. Start the server with --embeddings and mode=hybrid adds embedding similarity on top:

# Against a model on your own machine — no key, and no team document leaving it.
teamapi serve-api examples/acme-org --embeddings --embeddings-url http://localhost:11434/v1 \
  --embeddings-model nomic-embed-text

curl -s 'http://127.0.0.1:3000/search?q=who+handles+card+payments&mode=hybrid' | jq '.[0]'
{
  "kind": "service",
  "teamId": "platform-payments",
  "label": "payments-api",
  "similarity": 0.61,
  "matchedBy": "semantic"
}

Hybrid search keeps exact matches first. Embeddings help when a question shares no words with the documents. They hurt a query such as checkout-api, where the searcher knows the exact name and nearest-neighbour search may return several similar services. A lexical hit, an exact substring of something the org wrote down, therefore ranks above every semantic-only result. The scores are not blended because they use different scales, and this project has no benchmark for tuning a weighting between them. A result found both ways is marked both and ranks strongest.

POST /context takes semantic: true for the same treatment. matchedTerms still says which goal words matched, so a bundle stays explicable even when similarity is what moved an entry up.

Without a configured model, both return 400 naming the required flag. They do not silently return substring results for a semantic-search request.

Vectors are cached on disk (.teamapi-cache/embeddings, keyed by content and model id), because a --watch server otherwise re-embeds the whole org every time anyone saves a file.

Exposing it beyond localhost#

The API binds 127.0.0.1 and requires no credential. That default fits a laptop or local checkout used by one person. Once the port is reachable elsewhere, it exposes the full org graph: every person in the company, their contact details, and who reports to whom.

TeamAPI refuses to bind a non-loopback address without a token:

$ teamapi serve-api examples/acme-org --host 0.0.0.0
Refusing to listen on 0.0.0.0 without a token: this would serve the whole org graph,
including every member's contact details, to anything that can reach this port.
Pass --token <token> (or set TEAMAPI_API_TOKEN), or --allow-anonymous if that is really what you want.

A warning would scroll past in a terminal nobody is watching, and an exposed server looks exactly like a working one.

Flag Effect
--host <host> Address to bind (default 127.0.0.1)
--token <token> Require Authorization: Bearer <token>; defaults to $TEAMAPI_API_TOKEN
--cors-origin <origin…> Allow cross-origin browser requests from these origins (default: none)
--rate-limit <per-minute> Cap requests per minute per client IP (default: no limit)
--allow-anonymous Serve a reachable address with no token anyway
TEAMAPI_API_TOKEN=$(openssl rand -hex 32) teamapi serve-api examples/acme-org \
  --host 0.0.0.0 --rate-limit 120 --cors-origin https://intranet.example

/health stays open so liveness probes work, and /slack/* keeps authenticating with Slack's own request signature — which is stronger than a shared token, and the only thing Slack can actually send. Everything else needs the token.

Token comparison is constant-time, and a rejection never echoes the presented credential back into the response or the logs. Failed attempts are counted by the rate limiter, so a token can't be guessed at line rate.

Staying current#

Both servers resolve the graph once at startup. --watch keeps it current:

teamapi serve-api examples/acme-org --watch
teamapi serve-mcp examples/acme-org --watch

Three events use the same reload path: a watched document changes, POST /reload is called (mount it without watching via --reload-endpoint for a post-receive webhook), or the process receives SIGHUP.

Watching is anchored on the directory you pointed at, and seed discovery re-runs on every reload, so a new teamapi.yml is picked up rather than only edits to the files that existed at startup.

A failed reload never replaces a working graph. A document saved by an editor is briefly truncated, and a reload landing in that window would otherwise resolve an org missing half its teams — so the store publishes only on success, logs the failure, and keeps answering from the last good state until the file is valid again:

Reload failed, still serving the last good graph: Invalid Team API document at …
Reloaded: 4 team(s), 0 unresolved reference(s).

--watch matters most for serve-mcp: an assistant holds that connection open for an entire session, so without it the answers come from whatever the org looked like when the editor started.

Scale#

Resolution loads a whole BFS level at once. Org-graph levels are often wide; every team served by a platform team sits on one level. Loading documents serially made resolution time equal the sum of every round trip. Concurrent loading reduces it to the slowest round trip at each level.

Documents are still processed in a fixed order even though they're loaded concurrently, so first-writer-wins decisions (which document owns a duplicated team id, in what order unresolved references are reported) never depend on which fetch happened to return first. Two runs over the same seeds produce byte-identical graphs, and there's a test that pins exactly that against a 400-team fixture at concurrency 1, 8 and 64.

https:// refs also get an on-disk cache, enabled by default for the CLI. A fresh entry is served without a request. A stale one is revalidated with If-None-Match, and a 304 response carries no body. The cache is advisory: an unwritable, missing, or corrupt cache falls back to a plain fetch without failing the build.

Variable Default Meaning
TEAMAPI_CACHE_DIR .teamapi-cache/http Where cached remote documents live.
TEAMAPI_NO_CACHE unset Any non-empty value resolves without the cache.
TEAMAPI_RESOLVE_CONCURRENCY 8 Documents in flight at once; 1 is strictly serial.

These settings use environment variables because teamapi.config.yml lives in the repository and describes the org, while a cache directory belongs to the machine. CI needs it where the cache action can restore it; a container needs it on a writable volume.

pnpm bench:resolve measures resolver limits. It generates a synthetic org of any size and resolves it at several concurrency levels, either from a whole directory (what teamapi validate ./org does) or from a single root document whose $refs reach the rest (what a remote org looks like). At 5ms per document, a 200-team org resolves like this:

200 teams @ 5ms/doc
  seeds=all  concurrency      total   speedup
                        1   1235ms
                        8    185ms   6.7x
                       32     95ms   13.0x

On a local filesystem the loads are already cheap enough that the win is nearer 1.6x, and 1000 teams resolve in about a third of a second.