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.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().
/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 throughaddBff() 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, soBFF_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.
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.
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.
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: theX-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:
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.
Timeouts and failures
A back-end that accepts the connection and then does not answer withinBFF_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.
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.