A chat over any CompanyGraph MCP host. A visitor types a question on a site, this service asks the site's MCP host through its tools, and Claude Sonnet 5, on Vertex AI in the deployment's own project or on the Anthropic API with a key, writes the answer from what the tools said, naming the entity each claim rests on. It holds no model and pins no commit: what it answers is what the host answers, at the commit every answer of the host names. A re-pin of the host is a change the chat sees without a deploy. A refresh of the host's types that fails holds off trying the host again for thirty seconds, meanwhile answering from what was last read, so a degraded host is asked once and not once a message.
The design is docs/superpowers/specs/2026-09-22-chat-server-design.md. The routes, the events and the codes are docs/INTERFACE.md.
POST /chat takes the conversation and answers a stream of events: the text as it is generated, a cite for every entity a tool answered with on its own, names for every entity a list answer named, and for every entity an entity answer references past the fifty edges it holds, read from the host's list_references without the model seeing them, and done with what the message cost. The model runs at most four tool rounds a message over the host's own tools, and the last request tells it so, since a model told only by the request's tool choice that it may not call another answers with nothing, and an answer of no text is asked for once more as that last request with an empty tool list, since the same request again was answered the same way and a model with nothing to call writes, with the host's own instructions as the start of its prompt, then the host's types with how many entities each holds, read at connect and again when the host's commit moves, then a few rules: every claim from a tool, every claim naming its entity, the model does not say where it does not, a kind of thing is listed by its type and a named thing searched for by its English words whatever the visitor's language, since the model's prose is English, it writes a Markdown subset the widget renders, paragraphs, emphasis, code spans, lists and tables, and nothing else, with an address written bare and only where a tool answered with it, it answers every question about the model through a tool, and it answers in the language of the visitor's message, the page's when the message does not tell, naming each entity in that language with its title beside it exactly as the model writes it, since the widget links the title; a note after every round's tool answers says the same, the last thing the model reads before it writes, because the rule alone was not followed.
/ is a page for whoever types the host into a browser, built as the MCP host's own page is built, from the model's own words, and styled by the deployment's stylesheet.
Nothing here can spend more than its deployment wrote down. A message is at most 1,000 characters, the model sees the last eight turns, a tool's answer is cut at 32,000 characters and a list is asked for in pages of at most 50, so a page arrives whole, the output stops at 2,400 tokens, and a message runs at most four rounds, so one call has a known ceiling. An address gets twenty messages an hour, on a message and on GET /chat alike, and a refusal for that or for a spent share names the moment it lifts, so the widget's sentence is the server's truth and not a guess. A meter in one Firestore document counts every call in input-equivalent tokens against a day's share and a month's ceiling and refuses the next message when either is spent; a closed flag in the same document switches the chat off without a deploy. Every refusal is made before the model is asked and costs nothing. Once a message the shape accepted, from a page the deployment named, has its answer or its refusal, one line is kept of it, the question as sent, trimmed, and what the loop saw of it, no address and no word of the answer, in a log bucket of the project's own for ninety days, under the label logger set to chat.question, as every line the service writes names its logger, and the interface document names its fields. Around it, the deployment lowers the model's quota on the project and raises its budget.
The bucket holds behind Firebase Hosting, where Google's front end writes the visitor's address into X-Forwarded-For at the position CHAT_PROXY_HOPS counts back from. A caller that reaches the Cloud Run run.app address directly writes that header itself and so chooses its own key, and there the meter alone bounds it — which is why the deployment names its run_host and the service refuses its own run.app address to a page. The shape the front end actually writes is read from the first deployment's GET /chat, whose address field is the key the bucket used, and recorded in this paragraph; it has not been read yet.
CHAT_MCP_URL=https://mcp.example.test/mcp CHAT_ORIGINS=https://example.test CHAT_MONTH_TOKENS=18500000 \
CHAT_PROJECT=example-project CHAT_REGION=eu CHAT_METER=memory \
npx --package github:companygraph/chat-server companygraph-chat-http --model fake--model fake answers one sentence and calls no tool; without it the service calls Vertex AI with the credentials the environment gives it. With ANTHROPIC_API_KEY set the service calls the Anthropic API instead of Vertex AI, the same model and the same request; the key is the credential and the provider follows it. With ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID and ANTHROPIC_SERVICE_ACCOUNT_ID set instead, and ANTHROPIC_WORKSPACE_ID where the rule spans more than one workspace, the service calls the Anthropic API with no key: it asks the Google Cloud it runs on for its own identity token and trades it for an Anthropic token that lives minutes, which is how a deployment runs; a key stays the way to run it on a machine. A key and the federation ids together, or only some of the ids, stop the service at start with the names, since the key would otherwise win without a word. CHAT_METER is firestore, the default, or memory, which keeps it in the process, or table, Azure Table Storage, which needs CHAT_TABLE_URL and AZURE_CLIENT_ID; CHAT_IDENTITY is google, the default, or azure, Container Apps' managed-identity endpoint, which needs IDENTITY_ENDPOINT, IDENTITY_HEADER, AZURE_CLIENT_ID and CHAT_IDENTITY_AUDIENCE; CHAT_LOG chooses the log line's form, google, the default, or plain, which names the logger at the top of the line. Any other value stops the service at start with the values there are. The cloud packages each choice needs are optional dependencies, which npm ci installs. CHAT_HOSTS names the hosts the service answers to, unset meaning any; CHAT_PROXY_HOPS is how many front ends sit before it, one by default. CHAT_PROJECT and CHAT_REGION are needed only for Vertex AI. CHAT_QUESTION_INDEX_CHARS caps the prompt's line of the model's own question titles, 4,000 by default; a host with no type question carries no such line regardless.
A deployment of an MCP host adds a chat/ directory holding package.json pinning this package by tag, chat.json, a Dockerfile, brand.html, own.css and a test/ calling registerDeploymentTests() from companygraph-chat-server/deploy/tests; an infra/chat/ root of one file calling deploy/google/terraform at the same tag; and a workflow calling .github/workflows/deploy-google.yml at the same tag. A deployment also adds .github/workflows/report.yml, a schedule on Monday morning, cron: "0 6 * * 1", and a workflow_dispatch, calling this package's report-google.yml at the same tag, which writes the week's questions to the project's private reports bucket as chat-analyst; the caller grants contents: read and id-token: write. The report covers the ISO week that ended, its Monday at midnight UTC to the next, whatever hour the schedule fires, so a run on any day rewrites the same file; companygraph-chat-deploy report 2026-W37, run in chat/ as the analyst, rebuilds a named week while its lines are kept. The four places name one release, and the pin test, which reads every workflow, holds them to it. That package.json also names two devDependencies of the deployment's own: @robertblust/design, which page-css resolves for the family's fences and the fonts it carries into the sheet, and playwright, which the generic page test imports and whose browser the workflow installs on its own. chat.json also says provider, vertex by default or anthropic; with anthropic the module mounts the project's Secret Manager secret chat-anthropic-key into the service, and the owner makes that secret, adds the key as its first version and grants the runtime read access before the deploy, as the owner's steps say, and a deployment's infra/chat/main.tf passes it as model_provider = lookup(local.c, "provider", "vertex") — the module's own variable is named model_provider since Terraform reserves provider as a variable name. A deployment on the Anthropic API names anthropic_federation in chat.json, an object of rule_id, organization_id, service_account_id and, where needed, workspace_id, which infra/chat/main.tf passes as anthropic_federation = try(local.c.anthropic_federation, null); the module then sets those ids in the service's environment and mounts no secret. The ids are no credential and belong in the repository, since only a token Google signs for the project's chat-run passes the rule. chat.json carries domain, site_id, mcp_url, origins, month_tokens and, after the first apply, run_host. Before a deployment's first plan can post, its owner opens the chat's state once: terraform -chdir=infra/chat init, run locally on the pull request's branch under the owner's login, creates the empty state at the bucket's chat prefix, which a pull request's read-only plan identity may read but never create, since a backend meeting a prefix with no state creates one, lock and all, whatever init is told about locking. A deployment's first deploy fails at the live check by design: with run_host empty the service refuses its own run.app address, the apply's warning names that address, and the second deploy with it written passes, as the MCP host's did. site_id must differ from the host's site in the same project, and the image lands in the project's mcp registry beside the host's. The module needs the deploy identity to hold roles/datastore.owner, roles/resourcemanager.projectIamAdmin, roles/logging.configWriter and roles/storage.admin, and the plan identity roles/storage.bucketViewer and roles/iam.securityReviewer, since a pull request's plan refreshes the reports bucket and its policy once they exist; the MCP host's bootstrap grants all of them.
A deployment's chat/Dockerfile copies package.json and the lockfile and runs npm ci --omit=dev, then copies dist/page.css, which the build wrote from the design package with each font family's license text ahead of its faces, and so from @robertblust/design v0.83.0 or later, the first release that ships those texts, brand.html and, where the deployment has one, favicon.svg; its command is CMD ["node", "node_modules/.bin/companygraph-chat-deploy", "serve"], which starts this package's server in the container's one process with that page.
A deployment on Google can check its answers: chat.json names verdict: true, which infra/chat/main.tf passes as verdict = lookup(local.c, "verdict", false), and where a threshold has been measured verdict_threshold, passed as verdict_threshold = lookup(local.c, "verdict_threshold", null). The service then reads each answer back claim by claim against the tool answers the model was given, asks TypeSafe's Jev whether those answers carry each claim, and sends the widget a verdict event and the kept line two counts, as docs/INTERFACE.md says. TypeSafe receives the answer's sentences and the model's public text they rest on, never the visitor's question or address, and before any deployment turns this on its site's privacy page names TypeSafe as a processor of the answer, in English and in German. The key is the owner's, in the project's secret, made once with the key on stdin: gcloud secrets create typesafe-key --replication-policy automatic --project <project>, printf '%s' "$KEY" | gcloud secrets versions add typesafe-key --data-file=- --project <project>, and gcloud secrets add-iam-policy-binding typesafe-key --member serviceAccount:chat-run@<project>.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project <project>. Its spend limit is set at TypeSafe, which is the hard stop: the meter counts the chat's own model and has no unit for a second provider. A chat on Azure does not offer the check in this release, since its module holds no secret. node scripts/measure-verdict.mjs, run by hand with TYPESAFE_API_KEY set and never in CI, prints how often each tenth of probability was right over answers with a known fault planted in each, in English and in German; a threshold is read off that curve and never guessed.
A deployment on the Anthropic API can name where the API runs each request: chat.json names inference_geo, global for any geography Anthropic chooses or us for the United States only, which infra/chat/main.tf passes as inference_geo = lookup(local.c, "inference_geo", null) on either cloud, and the service sends it as inference_geo with every request. Left out, a request names none and the Console workspace's default_inference_geo decides, so a deployment that re-pins changes nothing it did not ask for. It is the Anthropic API's own setting: Vertex takes its region from the endpoint, so the deployment test and the service both refuse it with provider vertex. US-only processing is billed at 1.1 times the standard rate. A privacy page that names the country a visitor's message goes to needs it set, because global names none; the workspace's allowed_inference_geos in the Console is what makes the API refuse any other region, whatever the code sends.
Five steps are the owner's, because Terraform cannot do them. Claude's terms are accepted and Sonnet 5 enabled in Vertex AI's Model Garden, once per project: no Terraform resource does it, and until it is done the first message fails as internal rather than as a refusal with a code, since the fence has nothing to refuse. A deployment on the Anthropic API instead makes a workspace in the Anthropic Console with a monthly spend limit, which is the hard stop Google does not give, and connects the service to it once under Settings, Workload identity, Connect workload, Google Cloud: the issuer https://accounts.google.com, made once for the organization; an Anthropic service account for this deployment, a member of that workspace; and a rule with audience https://api.anthropic.com matching the claims sub, the number gcloud iam service-accounts describe chat-run@<project>.iam.gserviceaccount.com --format='value(uniqueId)' --project <project> prints, and email, chat-run@<project>.iam.gserviceaccount.com, scope workspace:developer, lifetime six hundred seconds; the rule's, the organization's and the service account's ids then go into chat.json. Never match Google's sub by prefix: it is a bare number, and a prefix admits service accounts of any project. A deployment that cannot federate keeps a key instead, in the project's secret, made once with the key on stdin and never in a file: gcloud services enable secretmanager.googleapis.com --project <project>, gcloud secrets create chat-anthropic-key --replication-policy automatic --project <project>, printf '%s' "$KEY" | gcloud secrets versions add chat-anthropic-key --data-file=- --project <project>, and gcloud secrets add-iam-policy-binding chat-anthropic-key --member serviceAccount:chat-run@<project>.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project <project>. Model Garden and the quota then do not apply. The model's quota is lowered on the project's Quotas page to a rate a chat needs and an attack does not, on the order of sixty requests and 300,000 input tokens a minute, which is the one layer that holds before this package runs a line. The module enables the Cloud Quotas API, so the same request can be filed from a terminal, gcloud beta quotas preferences create --service aiplatform.googleapis.com --quota-id EuOnlinePredictionRequestsPerMinPerProjectPerBaseModel --dimensions base_model=anthropic-claude-sonnet --preferred-value 60 --email <login> --justification <why> --project <project> --billing-project <project>, where the billing project keeps the request off whichever project gcloud happens to be set to, and a new project's first request may be refused until its billing account has history. The domain's records are set at the DNS provider from the dns_records output of infra/chat/, which the apply prints. The owner's own reading of the questions is granted once per project, with the login's address that belongs in no repository: gcloud iam service-accounts add-iam-policy-binding chat-analyst@<project>.iam.gserviceaccount.com --member user:<login> --role roles/iam.serviceAccountTokenCreator --project <project>; a read then runs as the analyst and as nothing more, under the owner's own gcloud auth login, with gcloud logging read '' --freshness=90d --impersonate-service-account chat-analyst@<project>.iam.gserviceaccount.com --bucket chat-questions --location <region> --view questions --project <project> --format json, since the flag names the account every command acts as and the freshness reaches back over the whole bucket where the default is a day, and a report with gcloud storage cat gs://chat-reports-<project>/reports/<week>.md --impersonate-service-account chat-analyst@<project>.iam.gserviceaccount.com. Running report on the machine instead of in the workflow needs application-default credentials that impersonate the analyst, gcloud auth application-default login --impersonate-service-account chat-analyst@<project>.iam.gserviceaccount.com, which the auth library reads and gcloud does not. And the chat is stopped by hand where the meter keeps it: the project's (default) Firestore database, document chat/meter, field closed set to true, which refuses the next message with closed and spends nothing, and back to false to open it again — the day and the month that document counts are UTC, so a share turns over at midnight UTC and not at midnight in Zürich.
One message is sent by hand after the second merge, because the deploy's live check is a GET /chat and a GET proves the route, the origins, the ceiling and the MCP host and never the model, the region or the runtime's role binding:
curl -N -H 'X-Chat: 1' -H 'content-type: application/json' https://<domain>/chat \
-d '{"messages":[{"role":"user","content":"What does the model say about the owner?"}],"lang":"en"}'The answer streams, its cite events name entities of the deployment's own model, and the done event's spent is what the message cost in the meter's unit. A second message within five minutes costs a fraction of the first, because the prefix is then read from the cache rather than written; cache_read_input_tokens itself never leaves the service, so that drop in spent is what a cache hit looks like from outside.
A deployment on Azure names "platform": "azure" in its deployment.json, which the MCP host's README describes with the bootstrap, and its chat runs in the host's Container Apps environment, so the host's first apply comes before the chat's. chat/ holds what it holds on Google, with a chat.json naming domain, mcp_url, origins, month_tokens, provider anthropic, anthropic_federation with its audience, and storage_account, a name of 3 to 24 lowercase letters and digits that is free across Azure; the deployment test refuses Google's fields there and names any field missing. infra/chat/main.tf calls deploy/azure/terraform at the pinned tag with an azurerm backend on the bootstrap's state account, key chat, and the providers azurerm with storage_use_azuread = true and resource_provider_registrations = "none", and azapi; the workflow calls .github/workflows/deploy-azure.yml and the weekly report .github/workflows/report-azure.yml, both at the same tag. The service runs with CHAT_METER=table, CHAT_IDENTITY=azure and CHAT_LOG=plain, which the module sets.
A chat on Azure reaches the Anthropic API by federation alone, and four steps are the owner's, once per tenant or once per deployment. In the tenant, one app registration stands for the Claude API: az ad app create --display-name "Claude API" --sign-in-audience AzureADMyOrg, then az ad app update --id <appId> --identifier-uris api://<appId> --set api.requestedAccessTokenVersion=2 and az ad sp create --id <appId>; its appId is anthropic_federation.audience. In the Anthropic Console, under Settings, Workload identity, an issuer for the tenant, https://login.microsoftonline.com/<tenant>/v2.0, discovery mode, with max_jwt_lifetime_seconds raised to 86400 after the wizard, since Azure caches a managed identity's token for up to a day; then a rule whose audience is the appId and whose claims are the chat-run identity's object id in oid, the run_principal_id output, and the tenant in tid. After the first apply, the domain's two records from the dns_records output are set at the DNS provider, the CNAME pointing straight at the app's own name, and chat.json gains app_host, dns_ready: true, questions_workspace_id and analyst_client_id from the outputs; the second apply adds the domain and its certificate. The first deploy's live check fails by design until app_host is written, as on Google. The pull-request plan identity, terraform-plan, is Reader only; if the chat's first pull-request plan fails to refresh a resource for want of an action, the fix is the bootstrap's, as the MCP host's bootstrap README says.
The kept questions live ninety days in the workspace chat-questions, which holds nothing else; the owner's own reading is granted once, az role assignment create --assignee <login> --role "Log Analytics Data Reader" --scope <workspace id>, and read with az monitor log-analytics query -w <questions_workspace_id> --analytics-query "ContainerAppConsoleLogs | project TimeGenerated, Log" -t P90D. The chat is stopped by hand where the meter keeps it: the entity with partition chat and row meter in the account's table chat, field closed set to true. Container Apps ends an HTTP request after 240 seconds, so an answer that needs the full five rounds of sixty seconds can be cut short on Azure where Cloud Run's 300 seconds would not; the first deployment measures how often.
The JavaScript in lib/, bin/ and deploy/ is the source and what runs, and its types are JSDoc in the same files. The declarations npm run build writes from that JSDoc into types/ are committed, and each subpath of the package points a TypeScript consumer at its own, so a consumer that takes the package from a tag gets the JavaScript and its types and builds nothing. The declarations name the Anthropic SDK's, the MCP client's and Node's own types, so a consumer that checks them with skipLibCheck off needs @types/node. The cloud SDKs' types appear only in the platform adapters' declarations, which no subpath reaches, so a consumer checks without the optional packages installed. A tool's answer and the deployment's own two JSON files are read key by key where they are used, so a declaration that takes one says any, as does one that takes an adapter's options, and no other does. TypeScript and Node's types are development dependencies and nothing else is: they are what npm run build:check runs on, and no consumer installs them. Why the types are JSDoc and not TypeScript source is in meta-model's docs/superpowers/specs/2026-09-30-types-from-jsdoc-design.md.
npm test runs the suite against a real MCP host, the server package's own over the meta-model's worked example, started in-process, and a scripted model; nothing reaches Vertex AI, Firestore or a live host.