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

> Let a trusted browser UI call your Forest back-end over REST.

The Forest BFF is a REST gateway that sits between a browser UI and your Forest back-end. It gives a third-party interface a flat HTTP contract for listing records, browsing relations and running actions, without that interface having to learn MCP or JSON:API.

It is what the [Zendesk app](/product/embed/zendesk) runs on.

<Info>
  Use the [Forest MCP Server](/product/embed/mcp-server) instead when you are building for an AI assistant. The BFF serves interfaces people click in, MCP serves tools models call.
</Info>

## Your back-end stays the authority

The BFF forwards calls, 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 /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 is not enforcing anything, your back-end is.
</Warning>

## Running it

Both deployments serve the same REST contract, and only the base URL changes. Embedded, `@forestadmin/agent-bff` is an optional peer dependency of `@forestadmin/agent`, so install it too or the agent throws on `start()`.

<CodeGroup>
  ```javascript Embedded in a Node.js back-end theme={null}
  await createAgent(options)
    .addDataSource(/* ... */)
    .addBff({
      allowedOrigins: ['https://my-app.com'],
      tokenEncryptionKey: process.env.BFF_TOKEN_ENCRYPTION_KEY,
    })
    .mountOnStandaloneServer(3351)
    .start();
  ```

  ```bash Standalone theme={null}
  npm install @forestadmin/agent-bff

  BFF_ALLOWED_ORIGINS=https://my-app.com \
    BFF_TOKEN_ENCRYPTION_KEY=xxx \
    AGENT_URL=https://your-agent.example.com \
    FOREST_ENV_SECRET=xxx FOREST_AUTH_SECRET=xxx \
    FOREST_SERVER_URL=https://api.forestadmin.com \
    FOREST_APP_URL=https://app.forestadmin.com \
    npx forest-bff
  ```
</CodeGroup>

Embedded, the BFF answers under `/bff` on the agent's own port, so `{your-agent-url}/bff/agent/v1/...`. Standalone, it listens on its own port, `3450` by default. Embedding needs the Node.js agent; with a Ruby back-end, run it standalone.

Prefer embedded for a single deployment, which is most of them. Standalone when the BFF and the agent have to scale separately. A standalone BFF forwards to the single back-end `AGENT_URL` names, so several agents cannot share one.

<Info>
  The full `agent.addBff()` signature, the body-parser ordering trap and the IP whitelist caveat live in the [Node.js agent reference](/reference/agent-api/nodejs).
</Info>

## Routes

The data routes live under `/agent/v1`. They are `POST`, and the filter, projection, sort, search and paging all travel in the body, never in the query string.

| Route                                                         | Method | What it does                                                                                                                                 |
| ------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `/agent/v1/{collection}/list` · `/count`                      | `POST` | list or count records of a collection                                                                                                        |
| `/agent/v1/{collection}/relations/{relation}/list` · `/count` | `POST` | the same, through a to-many relation                                                                                                         |
| `/agent/v1/{collection}/actions/{action}/form` · `/execute`   | `POST` | load an action's form, then run it                                                                                                           |
| `/agent/v1/context`                                           | `GET`  | the exposed schema: collections, typed fields, relations and actions                                                                         |
| `/agent/v1/permissions`                                       | `GET`  | display hints, never an authorization decision                                                                                               |
| `/agent/v1/ai/query`                                          | `POST` | the AI relay, served only when the deployment configures it. Needs an OAuth session: an `X-Forest-Bff-Key` call answers `403 oauth_required` |
| `/agent/openapi.json`                                         | `GET`  | the OpenAPI document, when enabled                                                                                                           |

Four routes sit outside the `/agent/v1` prefix:

| Route              | Method | What it does                                                        |
| ------------------ | ------ | ------------------------------------------------------------------- |
| `/oauth/authorize` | `GET`  | starts the browser sign-in, redirecting to the Forest front         |
| `/oauth/token`     | `POST` | exchanges the authorization code, and refreshes the session         |
| `/health`          | `GET`  | the deployment probe                                                |
| `/docs`            | `GET`  | a Redoc viewer over the OpenAPI document, served on the same switch |

The two `/oauth` routes are what sign-in runs on, so an ingress or WAF that allow-lists only `/agent/*` and `/health` breaks it. Embedded, all of them sit under the `/bff` prefix like the rest.

To-one relations are not listable and answer `404 unknown_relation` by design.

Errors use a type-first contract, `{ error: { type, status, message, details? } }`, so branch on `error.type` and never on message text.

`POST /agent/v1/ai/query` is the one exception, because it relays the Forest server. Its own refusals follow the contract, and a non-JSON or `5xx` answer from upstream keeps that status but has its body replaced with a typed `upstream_error`. A JSON answer below `500` is relayed verbatim, with the upstream status and the upstream's own shape, so a validation error arrives as `{ errors: [{ detail }] }` and carries no `error.type` to branch on. Read that route's `4xx` bodies as the Forest server's.

## Configuration

Standalone deployments are configured entirely through environment variables. Embedded, the secrets and Forest URLs are inherited from the agent, and the rest is set through `addBff()` alone: the environment is not read at all, so a variable marked "Unused when embedded" below is ignored without a warning and the default stands.

| Variable                      | Required    | Default  | Description                                                                                                             |
| ----------------------------- | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `FOREST_AUTH_SECRET`          | Yes         | —        | Your authentication secret. Must match your back-end's.                                                                 |
| `FOREST_ENV_SECRET`           | Yes         | —        | Your environment secret.                                                                                                |
| `FOREST_SERVER_URL`           | Yes         | —        | Forest API base URL.                                                                                                    |
| `FOREST_APP_URL`              | Yes         | —        | Forest front base URL, used to build the sign-in redirect.                                                              |
| `AGENT_URL`                   | Yes         | —        | The back-end the BFF calls. Unused when embedded, where the BFF reaches it in-process.                                  |
| `BFF_TOKEN_ENCRYPTION_KEY`    | For sign-in | —        | Base64-encoded 32-byte key. Until it is set, nobody can sign in from a browser UI.                                      |
| `HTTP_PORT`                   | No          | `3450`   | Server port. Unused when embedded, where the BFF answers on the agent's port.                                           |
| `BFF_ALLOWED_ORIGINS`         | No          | empty    | Comma-separated allow-list of origins, exact or one leading wildcard label. Empty means no cross-origin browser access. |
| `BFF_DEFAULT_TIMEZONE`        | No          | —        | Fallback IANA timezone when a request carries none.                                                                     |
| `BFF_PUBLIC_URL`              | No          | —        | The BFF's own external base URL, published in the OpenAPI document. Unused when embedded.                               |
| `BFF_AGENT_TIMEOUT_MS`        | No          | `10000`  | How long to wait for your back-end on a data or action call.                                                            |
| `BFF_AI_TIMEOUT_MS`           | No          | `120000` | How long to wait for the Forest server on an AI query, `POST /agent/v1/ai/query`.                                       |
| `BFF_RATE_LIMIT_MAX_REQUESTS` | No          | `300`    | Requests allowed per caller per window. Unused when embedded.                                                           |
| `BFF_RATE_LIMIT_WINDOW_MS`    | No          | `60000`  | Length of that window. Unused when embedded.                                                                            |
| `BFF_OPENAPI_ENABLED`         | No          | `true`   | Serve the OpenAPI document over HTTP. `false` when embedded.                                                            |

A malformed value fails the boot with a clear error and never echoes the offending value. `BFF_ALLOWED_ORIGINS` is the exception: an entry that cannot be parsed is dropped, the boot carries on, and a startup warning names the entries it dropped. Read that warning, because a typo there leaves you with a narrower allow-list than you wrote, and the symptom only shows up later as `403 origin_not_allowed`. A required variable that is merely absent boots anyway and reports the gap through `/health`.

## Cross-origin access

A browser UI is cross-origin by definition, so `BFF_ALLOWED_ORIGINS` (or `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, and how it is refused depends on the preflight. A request arriving without one answers `403 origin_not_allowed`, rather than running and then being served without the header for the browser to discard. A preflighted one is stopped one step earlier, at the `OPTIONS`, which is what the warning below is about.

<Warning>
  A browser rarely shows you that `403`. Every data route is a `POST` with a JSON body, so the browser sends a preflight first, and a rejected `OPTIONS` answers `204` with no CORS headers — the browser then reports a plain CORS error and never issues the request that would have carried the `403`. What names the cause is the BFF'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.
</Warning>

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, and the wildcard is only accepted in that position: anywhere else in the host, more than once, or over a host with fewer than two remaining labels, the entry is refused at boot and named in the startup warning.

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, so the entry is 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 but silent. Check the startup log lists the entries you expect.

<Note>
  A wildcard widens the allow-list to every host under that domain, including ones 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 rather than reaching for the wildcard by default.
</Note>

A request carrying no `Origin` at all is untouched, which is every server-to-server call. The opaque `Origin: null` is different: it 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 BFF.

<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 URL in 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

On every route that reaches your back-end, the BFF forwards an explicit timezone, resolved in order: the `X-Forest-Timezone` header, then a `timezone` field in the body, then the fallback the deployment configures. Standalone that fallback is `BFF_DEFAULT_TIMEZONE`. Embedded it is `defaultTimezone` in `addBff()`, and the environment variable is not read at all. None of the three gives `400 missing_timezone`, and a non-IANA value gives `400 invalid_timezone`.

## Checking a deployment

```bash theme={null}
curl {your-bff-url}/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

`GET /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`. Exporting it without booting the server works too, which is what you want in CI:

```bash theme={null}
forest-bff openapi > openapi.json
forest-bff openapi --output docs/api.json
```

The document comes in two forms, and `info.description` says which one you are holding. An export that has your Forest configuration available is unfolded, with one path per exposed collection, to-many relation and action, each carrying its real field set. Without that configuration it is generic, one path per operation, with the collection, relation and action passed as path parameters and no field enumerated. The runtime routes stay generic either way, only the document unfolds.

<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. Set `BFF_OPENAPI_ENABLED=false` if that surface must not be reachable over HTTP. Exporting with the CLI keeps working either way.
</Warning>

## Timeouts and failures

A back-end that accepts the connection and then does not answer within `BFF_AGENT_TIMEOUT_MS` gives `504 agent_timeout`. A back-end that refuses the connection, whose host does not resolve, or that fails outright mid-flight gives `502 network_error`. The two are deliberately distinct, so a slow back-end and an unreachable one are not diagnosed as the same incident.

Over the rate limit, the BFF 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. Read both fields only when they are present: a 429 raised by your back-end instead is re-enveloped, keeping its status and its detail as the `message` but carrying neither field. Either way the body is the BFF'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>

## 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 a BFF you deploy.
  </Card>

  <Card title="agent.addBff() reference →" icon="arrow-right" href="/reference/agent-api/nodejs">
    The embedded options, the body-parser ordering trap and the IP whitelist caveat.
  </Card>
</CardGroup>
