Skip to main content
The Forest Gateway exposes your Forest back-end to two kinds of consumers from one mount:
  • The MCP service serves tools to AI assistants over the Model Context Protocol.
  • The API service, the “Gateway API”, is a flat REST contract for interfaces people click in. The Zendesk app runs on it.
One component means one set of routes, one OAuth namespace and one package to install. If you have read about the “Forest BFF” before, that product is now the API service of the Gateway. addBff() is a deprecated alias of the Gateway’s api option, and mountAiMcpServer() a deprecated alias of its mcp option.
Three names, three things. The Agent API is your agent’s own /forest routes, the ones the Forest UI calls (see the Agent SDK overview). The Forest API is Forest’s public reporting API (see its introduction). The Gateway API is neither: it is the REST surface the Gateway serves to your trusted UIs.

Permissions

The Gateway forwards calls and your back-end decides them. Every call reaches your back-end as the person who made it, and comes back filtered exactly as it would for them in Forest. Roles, permissions and scopes apply unchanged, and a collection your schema no longer exposes is re-checked on every call.
GET /api/agent/v1/permissions returns display hints, meant for graying out buttons an operator cannot use. It is never an authorization decision. A UI that treats it as one enforces nothing: your back-end does.

Audit trail

Every data and action operation through the API is written to your Forest activity logs, attributed to the signed-in user, exactly as if they had acted in the Forest UI. The MCP service logs the same way, with four tool exceptions listed on its page.

Adding the Gateway

Embedded in the Node.js agent, the Gateway needs @forestadmin/agent, plus @forestadmin/agent-bff only for the API service. That one is an optional peer dependency the agent loads at runtime when api is set, at exactly the version the agent declares. MCP alone installs nothing extra.
1

Install

Agent pinned below latest? Install the exact @forestadmin/agent-bff version your agent’s release declares instead. On a mismatch the agent refuses to start, and the error names the version it wants with the command to run, npm install @forestadmin/agent-bff@X.Y.Z.addGateway() ships with @forestadmin/agent 1.109.0. On an earlier agent the call throws agent.addGateway is not a function.
2

Enable the services you need

Call addGateway() once, before start(): a second call throws addGateway can only be called once, and so does a call once startup has begun. Enable at least one service, checked when the agent starts: addGateway({ mcp: true }) for MCP only, addGateway({ api: { ... } }) for the API only, or both as above. An argument-less addGateway() is a TypeError on the spot, and an empty addGateway({}) throws addGateway() enables no service when the agent starts.
3

Give the UI its URL

The URL a trusted UI calls is {your-agent-url}{basePath}/api, which is /api with the default empty basePath.Sign-in needs api.tokenEncryptionKey, a Base64-encoded 32-byte key. Generate one with:
4

Check the deployment

GET {your-agent-url}{basePath}/api/health must answer "status": "ok". The full probe contract: Checking a deployment.
Each service is off until you enable it:
  • The MCP service exposes the tools, their restrictions and their token lifetimes, all documented on the Forest MCP Server page.
  • The API service is configured through the api options. tokenEncryptionKey enables OAuth sign-in (and with it the AI relay), allowedOrigins enables browser access, openapiEnabled serves the API docs. The secrets, the Forest URLs and the logger are inherited from the agent: embedded, the Gateway API never reads the environment.
On a Ruby back-end, or any deployment where the agent cannot host the Gateway, run the standalone Gateway instead. It serves the same routes from its own process and port.

Routes

With an empty basePath (the default), the Gateway claims these routes at the root of whatever the agent is mounted on. Every route moves under the prefix when you set one; the table writes it {basePath}. The two .well-known documents only exist when the MCP service is on. OAuth discovery must stay at the origin root (RFC 8414/9728), so a basePath is carried as a suffix rather than a prefix. Every other /.well-known/* request reaches your host application. The /oauth/* namespace is shared. Both services run their own OAuth, and GET/POST {basePath}/oauth/authorize and POST {basePath}/oauth/token dispatch on one query parameter. {basePath}/oauth/*?service=api goes to the API service, and the same paths without a service go to the MCP service. Other values answer 400 invalid_request while the MCP service is on; an API-only Gateway claims only ?service=api on /oauth, and every other /oauth request reaches your host. ?service=api on a Gateway without the API service answers 404 not_found. API OAuth needs api.tokenEncryptionKey: without it the API’s /oauth side stays off, sign-in included. Only API callers send service; an MCP client never does. The data routes under /api/agent/v1 are POST, except GET for permissions and context, and the filter, projection, sort, search and paging all travel in the body, never in the query string. Errors use a type-first contract, { error: { type, status, message, details? } }, so branch on error.type and never on message text. The one exception is POST /api/agent/v1/ai/query, which relays the Forest server: its JSON answers below 500 keep the upstream’s own { errors: [{ detail }] } shape. To-one relations are not listable and answer 404 unknown_relation by design. The route layout never depends on which services are on: the API is always under {basePath}/api, even when it is the only service.

Choosing a basePath

Leave basePath empty when your host application does not need the root paths. Set it when the host already serves /api, /mcp or /oauth, or, with the MCP service on, when it needs the root .well-known documents. A basePath must be a plain path prefix like /gateway (letters, digits, - and _ only) and may not overlap the agent’s own mount prefix.
With the MCP service on, a non-empty basePath requires the agent to be served at the domain root. OAuth discovery is anchored at the origin root, so addGateway({ basePath: '/ai', mcp: true }) throws at startup if the agent’s own URL already carries a path (https://host/api). The root /.well-known/* requests must also reach the agent, so a proxy that forwards only /{basePath}/* breaks discovery. An API-only Gateway is not affected, since its discovery needs nothing at the root.

Ingress and network rules

An ingress or WAF that allow-lists only {basePath}/api/* breaks sign-in. The browser sign-in runs on {basePath}/oauth/*, so that namespace must pass too, for the API (?service=api) as for the MCP service. With the MCP service on and a non-empty basePath, the two discovery paths /.well-known/oauth-authorization-server{basePath} and /.well-known/oauth-protected-resource{basePath}/mcp must also reach the agent, even though they sit outside {basePath}.

Restricting which OAuth clients can connect

By default, any OAuth client application registered at Forest can connect. The Gateway’s top-level allowedOAuthClients restricts both services at once, MCP and API:
Entries are domains, not client ids. A client is allowed only when every redirect URI it registered is an http(s) URI on a listed domain or one of its subdomains (dust.tt matches eu.dust.tt). Matching uses redirect URIs because they are the one piece of registration metadata an impostor cannot benefit from. Every other client is rejected with a standard OAuth invalid_client error, and the response does not reveal the allowed domains. Keep forestadmin.com in the list as long as you use Forest’s Zendesk app. Its sign-in redirect runs through app.forestadmin.com, so without that entry the app itself gets rejected. Restrict which AI clients can connect covers native desktop clients, loopback redirect URIs, and the token lifetime of approvals granted before the restriction. The deprecated aliases do not share this scope. mountAiMcpServer() keeps its own allowedOAuthClients (MCP only), and addBff() has no allowlist: its API accepts any client registered at Forest.

Deprecated aliases, and when to keep them

mountAiMcpServer() and addBff() still work on their current paths and semantics: mountAiMcpServer() at the host root or under its own basePath, addBff() under /bff. Both are deprecated, though. mountAiMcpServer() logs its deprecation warning at startup; addBff() logs its own when /bff/* is first called, at most once an hour, and every /bff/* response carries Deprecation and Link headers. The Link points at the retired /product/embed/bff URL, which this documentation redirects to this page: that redirect stays in place for good.
addGateway() and the aliases cannot be combined. Calling one after the other throws at the builder call, not from start(): “addGateway() cannot be combined with mountAiMcpServer() or addBff(): configure every service through addGateway(), or keep both aliases.”. Configure every service through addGateway({ mcp, api }), or keep both aliases.
Two cases justify keeping both aliases instead of moving to addGateway():
  1. You want the MCP service restricted by allowedOAuthClients while the API stays open to any client registered at Forest. The Gateway’s list covers both services, so only mountAiMcpServer({ allowedOAuthClients }) plus addBff() gives you that split.
  2. The MCP service must stay at the root while your host already serves /api. With an empty basePath, the Gateway API would claim /api/agent/*, /api/docs and /api/health, and you cannot give the API alone a basePath the MCP does not use. Keep mountAiMcpServer() at the root and addBff() on /bff.

Moving from addBff() to addGateway({ api })

The migration is mechanical:
  • The addBff() options move under api unchanged: addBff({ allowedOrigins, tokenEncryptionKey }) becomes addGateway({ api: { allowedOrigins, tokenEncryptionKey } }).
  • The URL moves from /bff to {basePath}/api, which is /api by default. Update every application that called the old URL. In the Zendesk app, the forestBFFUrl setting changes from {your-agent-url}/bff to {your-agent-url}/api.
  • Users sign in again. Sessions live in memory in the agent process, and the routes they authenticated against have moved.

Mount before your body parser

On every framework, mount the agent before your application’s own body parser. A parser that runs first has already consumed the request stream, and nothing downstream can put it back. Every API POST on the data routes then fails with 500 stream.not.readable on Express, and with a bare 500 Internal Server Error on Koa. The OAuth routes ({basePath}/oauth/*?service=api) answer the host framework’s own bare 500 on every framework: the typed error body only covers the data routes.
NestJS needs no special handling as long as you mount before listen(). The MCP service is unaffected by the ordering, since its tool calls dispatch in-process, but mount it first anyway: one rule, no exceptions.

Cross-origin access

A browser UI is cross-origin by definition, so the API’s allowedOrigins decides what reaches your data. Matching is exact: scheme, host and port, case-insensitive, default ports normalized away, no trailing slash. An origin outside the list never reaches your data, with one exemption: a request from the very host serving the Gateway is allowed through, compared on host, so an application serving its own UI beside the mount works with an empty allow-list. How a refused origin is answered depends on the preflight:
  • A request without a preflight answers 403 origin_not_allowed.
  • A preflighted request is stopped one step earlier, at the OPTIONS.
A browser rarely shows you that 403. Every data route is a POST with a JSON body, so the browser sends a preflight first. A rejected OPTIONS answers 204 with no CORS headers, and the browser reports a plain CORS error without ever issuing the request that would have carried the 403. The cause shows up in the Gateway’s own log line, BFF preflight origin rejected, with the origin it refused. Read it there, or replay the call with curl -H 'Origin: ...' to see the 403 body.
An entry may carry one wildcard label at the front of the host, https://*.example.com, which needs @forestadmin/agent-bff 1.31.0 or later. It stands for exactly one subdomain label, so it covers https://app.example.com but neither https://a.b.example.com nor the bare https://example.com. Scheme and port still match exactly. The wildcard is refused at boot, and named in the startup warning, when it appears anywhere else in the host, more than once, or over a host with fewer than two remaining labels. One shape escapes that check. A * placed in the URL’s userinfo rather than the host, as in https://*@example.com, is stripped when the entry is normalized. The entry is then accepted as the exact origin https://example.com and no warning names it. Mistyping *. as *@ therefore allows the bare domain and none of its subdomains, which is narrower than intended and silent. Check that the startup log lists the entries you expect.
A wildcard widens the allow-list to every host under that domain, including hosts you do not control when the domain is shared. That is usually acceptable here, since the origin check is not the authentication boundary: a session or an API key is still required. Weigh it per domain.
A request carrying no Origin at all is untouched, which covers every server-to-server call. The opaque Origin: null counts as a present origin and is refused. A sandboxed iframe sends it, so give yours allow-same-origin if it has to reach the Gateway.
When your UI runs in an iframe, the Origin the browser sends is the iframe’s, not the page hosting it. Allow-listing the host application’s domain then refuses every call with 403 origin_not_allowed. Read the Origin header off a failing request in your browser’s network inspector rather than deriving it from the address bar.
Browsers enforce this against localhost too, so add your dev origin (for example http://localhost:4200) while developing. There is no development bypass.

Timezone

Every API route except context and ai/query requires an explicit timezone, resolved in this order: the X-Forest-Timezone header, a timezone field in the body, then the fallback set by api.defaultTimezone. The requirement covers permissions too, although that route never reaches your back-end. With none of the three, the answer is 400 missing_timezone, and a non-IANA value gives 400 invalid_timezone.

Checking a deployment

configured says which optional surfaces this deployment was set up to serve, not that they work. A 503 degraded means a required variable is missing. The body never names which keys are present, because that would leak your configuration to an unauthenticated probe. Missing keys are logged at startup for operators instead.

OpenAPI document

With api.openapiEnabled set, GET {basePath}/api/agent/openapi.json serves a document you can generate a typed client from. It sits behind the same credentials as the data routes, so an uncredentialed curl answers 401 unauthorized. Pass a session bearer or an X-Forest-Bff-Key.
The served document is not filtered per caller. Anyone who can reach it reads the name of every exposed collection, relation and field, whatever their role. That is why it is off by default when embedded. You can export it without booting the server, which is what you want in CI: the standalone Gateway’s forest-gateway openapi command works either way.

Timeouts and failures

Embedded, your agent runs in the same process: there is no socket, and a dispatch that fails or does not answer within api.agentTimeoutMs (default 10s) maps to 503 agent_unavailable. The 504 agent_timeout and 502 network_error types belong to the standalone Gateway, which calls your agent over HTTP: 504 when the agent accepts the connection and does not answer in time, 502 when it refuses the connection, its host does not resolve, or it fails mid-flight. The two are distinct on purpose, so a slow back-end and an unreachable one are not diagnosed as the same incident. Branch on error.type with that split in mind: an embedded client never sees a 504 or a 502 from its agent. ai/query is the exception: it relays the Forest server over the network and answers 502 network_error when that server cannot be reached and 504 upstream_timeout when it does not respond in time, embedded and standalone alike. The agentTimeoutMs bound cancels nothing. A write cut off at the deadline keeps running inside the agent and still commits, so a client retrying on the timeout can double the mutation. Raise the timeout rather than relying on a retry. Over the rate limit, the API answers 429 too_many_requests with a Retry-After and a details.cause saying whether the caller exceeded its budget or the limiter is saturated. Either way the body is the Gateway’s own { error: { type, status, message } }, never your back-end’s JSON:API errors array, so branch on error.type rather than parsing an upstream shape.
Sessions live in memory, in one process, and so does the deduplication of in-flight token refreshes. There is no shared store to plug in, so horizontal scaling needs sticky sessions. Without them, two calls for one session landing on different nodes each refresh the Forest token. The second presents a refresh token the first already rotated, and that call fails with session_expired.
Forest’s IP whitelist does not apply to requests served by the Gateway API. They reach the agent in-process, over what the whitelist treats as a trusted loopback caller. That is deliberate: applying the whitelist would filter the browsers a third-party UI is made of. Authentication is still required, either an OAuth sign-in or an X-Forest-Bff-Key API key.

Standalone deployment

The Gateway above is the embedded deployment, inside the Node.js agent. When the agent cannot host it, for a Ruby back-end or an API that must scale apart from the agent, run the standalone Gateway: the same routes and the same OAuth, from its own process and port, configured through environment variables. It ships as the @forestadmin/gateway package and the ghcr.io/forestadmin/gateway image.
forest-bff and forest-mcp-server, the standalone commands that came before the Gateway, keep working and are deprecated. forest-bff serves the API at its root rather than under /api. Moving to the Gateway covers what changes.

What’s next

Next: Zendesk app →

The Forest app for Zendesk, which runs on the Gateway’s API service.

Forest MCP Server →

The MCP service’s tools, restrictions, token lifetimes and uploads.