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

# Forest Gateway

> Expose your Forest back-end to AI assistants and trusted browser UIs through one embedded gateway.

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](/product/embed/mcp-server).
* **The API service**, the "Gateway API", is a flat REST contract for interfaces people click in. The [Zendesk app](/product/embed/zendesk) 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.

<Info>
  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](/reference/agent-api/overview)). The **Forest API** is Forest's public reporting API (see [its introduction](/reference/api/introduction)). The Gateway API is neither: it is the REST surface the Gateway serves to your trusted UIs.
</Info>

## 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.

<Warning>
  `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.
</Warning>

## 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](/product/embed/mcp-server#identity-auditing-and-limits).

## 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.

<Steps>
  <Step title="Install">
    ```bash theme={null}
    npm install @forestadmin/agent@latest @forestadmin/agent-bff@latest
    ```

    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`.
  </Step>

  <Step title="Enable the services you need">
    ```javascript theme={null}
    await createAgent(options)
      .addDataSource(/* ... */)
      .addGateway({
        mcp: true, // or MCP options: { enabledTools, tokenTtl, fileUploads }
        api: {
          allowedOrigins: ['https://my-app.com'],
          tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY,
        },
      })
      .mountOnStandaloneServer(3351)
      .start();
    ```

    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.
  </Step>

  <Step title="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:

    ```bash theme={null}
    openssl rand -base64 32
    ```
  </Step>

  <Step title="Check the deployment">
    `GET {your-agent-url}{basePath}/api/health` must answer `"status": "ok"`. The full probe contract: [Checking a deployment](#checking-a-deployment).
  </Step>
</Steps>

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](/product/embed/mcp-server).
* 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](/product/embed/gateway-standalone) 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}`.

| Route | Service | What it does |
| - | - | - |
| `POST {basePath}/mcp` | MCP | The MCP protocol endpoint (Bearer token required) |
| `{basePath}/mcp/uploads` | MCP | The in-memory upload endpoint for action file fields, served while uploads use the built-in in-memory storage (the default) |
| `{basePath}/oauth/authorize` · `/token` | MCP / API | OAuth, shared (see the `?service` rule below) |
| `{basePath}/api/agent/v1/*` | API | The REST data routes: `list`, `count`, action `form`/`execute`, `context`, `permissions`, `ai/query` |
| `{basePath}/api/agent/v1/{collection}/relations/{relation}/list` · `/count` | API | List or count a related collection |
| `GET {basePath}/api/health` | API | The deployment probe |
| `GET {basePath}/api/docs` | API | A Redoc viewer over the OpenAPI document, served only when `api.openapiEnabled` is set |
| `GET {basePath}/api/agent/openapi.json` | API | The OpenAPI document itself, served only when `api.openapiEnabled` is set |
| `/.well-known/oauth-authorization-server{basePath}` | MCP | OAuth discovery, anchored at the origin root |
| `/.well-known/oauth-protected-resource{basePath}/mcp` | MCP | MCP discovery, anchored at the origin root |

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.

<Warning>
  **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.
</Warning>

## 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:

```javascript theme={null}
.addGateway({
  allowedOAuthClients: ['dust.tt', 'forestadmin.com'],
  mcp: true,
  api: { tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY },
})
```

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](/product/embed/zendesk). Its sign-in redirect runs through `app.forestadmin.com`, so without that entry the app itself gets rejected. [Restrict which AI clients can connect](/product/embed/mcp-server#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.

<Warning>
  `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({ mcp, api }), or keep both aliases."*. Configure every service through `addGateway({ mcp, api })`, or keep both aliases.
</Warning>

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](/product/embed/zendesk), 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

<Warning>
  **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.

  ```typescript theme={null}
  const app = express();
  agent.mountOnExpress(app);  // first
  app.use(express.json());    // then yours
  await agent.start();
  ```

  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.
</Warning>

## 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`.

<Note>
  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.
</Note>

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.

<Warning>
  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.
</Warning>

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.

<Warning>
  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.
</Warning>

<Note>
  Browsers enforce this against `localhost` too, so add your dev origin (for example `http://localhost:4200`) while developing. There is no development bypass.
</Note>

## 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

```bash theme={null}
curl {your-agent-url}{basePath}/api/health
```

```jsonc theme={null}
{
  "status": "ok",
  "version": "<package version>",
  "configured": { "oauth": true, "ai": true, "cors": true, "openapi": true }
}
```

`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`.

<Warning>
  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`](/product/embed/gateway-standalone#exporting-the-openapi-document) command works either way.
</Warning>

## 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](/product/embed/gateway-standalone), 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.

<Warning>
  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`.
</Warning>

<Warning>
  **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.
</Warning>

## 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](/product/embed/gateway-standalone): 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.

<Note>
  `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](/product/embed/gateway-standalone#moving-from-forest-mcp-server-or-forest-bff) covers what changes.
</Note>

## What's next

<CardGroup cols={2}>
  <Card title="Next: Zendesk app →" icon="arrow-right" href="/product/embed/zendesk">
    The Forest app for Zendesk, which runs on the Gateway's API service.
  </Card>

  <Card title="Forest MCP Server →" icon="arrow-right" href="/product/embed/mcp-server">
    The MCP service's tools, restrictions, token lifetimes and uploads.
  </Card>
</CardGroup>


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