Skip to main content
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 runs on.
Use the Forest MCP Server instead when you are building for an AI assistant. The BFF serves interfaces people click in, MCP serves tools models call.

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

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().
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.
The full agent.addBff() signature, the body-parser ordering trap and the IP whitelist caveat live in the Node.js agent reference.

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. Four routes sit outside the /agent/v1 prefix: 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. 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.
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.
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.
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.
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.
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.
Browsers enforce this against localhost too, so add your dev origin (for example http://localhost:4200) while developing. There is no development bypass.

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

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

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

What’s next

Next: Zendesk app →

The Forest app for Zendesk, which runs on a BFF you deploy.

agent.addBff() reference →

The embedded options, the body-parser ordering trap and the IP whitelist caveat.