Farther ShoreDocs
Go to Farther Shore
What is FartherShore
Install the CLI
Quickstart
Core concepts
The @Product class
Meters & resources
Features & routes
@Feature optionsRoute keys: "METHOD /path"Route entries: cost, reports, estimates, actionsonStatusCodesActionsNon-HTTP work: @Workflow
Capabilities & entitlements
Plans & pricing
The Manifest IR
Bring your own backend
Transport modes
Metering & verification
Runtime tokens
Frontend SDK
Root & data components
Auth & sessions
Entitlement gates
Connect Stripe
Subscriptions & usage
Plan changes & grandfathering
Billing strategies
Apply & deploy
Environments
Migrations
Docs versions & archive
Operate with an agent
Operation classes
MCP server
End-to-end via CLI/MCP
CLI reference
@farthershore/product
@farthershore/backend
@farthershore/farthershore-js
Environment variables
Response & deny codes
Add a metered capability
Gate a feature
Change a price
Prepaid credits
Meter AI tokens
Operate via an agent
Prepare for launch
Status
Docs/Define your product/Features & routes

Features & routes

@Feature bundles gateway routes, the meters and actions they report, and the plans that grant them.

PreviousMeters & resourcesNextCapabilities & entitlements

A @Feature is a bundle of gateway routes plus the metering and actions those routes carry. Routes are a record keyed by "METHOD /path", in declaration order — order matters because the gateway matcher is first-match-wins. Cross-references (which meter a route reports, which plans grant the feature) are by string key.

import { Product, Resource, Capability, Feature, Plan } from "@farthershore/product";

@Product({ name: "croncloud", origin: "https://api.example.com" })
export default class CronCloud {
  @Resource("cron_jobs", { display: "Cron jobs", countSource: "action_inferred" })
  cronJobs!: unknown;

  @Capability("managed-cron", { title: "Managed Cron Jobs", includesFeatures: ["cron-jobs"] })
  managedCron!: unknown;

  @Feature("cron-jobs", {
    description: "Cron job CRUD",
    plans: ["starter", "pro"],
    actions: [
      { id: "cron-job.create", kind: "mutation", title: "Create cron job",
        resource: { resource: "cron_jobs", effect: "create" } },
      { id: "cron-job.delete", kind: "mutation", title: "Delete cron job",
        subject: { type: "cron_job", from: "path_param", name: "id" },
        resource: { resource: "cron_jobs", effect: "delete" }, audit: "full" },
    ],
    routes: {
      "GET /v1/cron-jobs": {},
      "POST /v1/cron-jobs": { action: "cron-job.create" },
      "DELETE /v1/cron-jobs/{id}": { action: "cron-job.delete" },
    },
  })
  cronJobsFeature!: unknown;

  @Plan("starter", { name: "Starter", limits: { requests: { rate: 600, interval: "minute" } } })
  starter!: unknown;
}

@Feature options

OptionTypeMeaning
routesRecord<"METHOD /path", entry>The gateway routes, in declaration order.
descriptionstringHuman description of the feature.
plansstring[]Plan keys that grant this feature directly.
actionsArray<{ id } & action>Action metadata bound to routes by id.
policiesstring[]Policy keys applied to the feature.
backendstringDefault backend id for all the feature's routes (per-route backend overrides).
mutationClass"runtime" | "contractual"Mutation classification.
cacheProfile"long" | "short" | "blocking"Cache behavior.
upstreamOriginstring | nullPer-feature origin override (default: the product origin).

Route keys: "METHOD /path"

Each route key is "METHOD /path", where METHOD is one of GET POST PUT PATCH DELETE HEAD OPTIONS *. The shape is enforced at decoration time — a bare key, a missing or unknown method, or an integer-like key throws a ManifestBuilderError immediately:

@Feature("f", { routes: { "no-slash": {} } })   // throws: must be "METHOD /path"
@Feature("f", { routes: { "0": {} } })           // throws: integer-like route key

* is a valid wildcard method ("* /catch": {}). Path parameters use braces: "DELETE /v1/cron-jobs/{id}".

Route entries: cost, reports, estimates, actions

Each route entry is an object. An empty {} means "inherit the product defaults" — for a product with @Requests, that is requests = 1. The fields:

FieldTypeMeaning
costRecord<meterKey, number>Fixed gateway-known cost, added on top of inherited defaults.
reportsstring | string[]Dynamic meter key(s) your backend reports after the request.
reportstringSugar for a single reports entry.
estimatesRecord<meterKey, number>Route-specific pre-request estimate for reported meters.
actionstringBind a declared action to this route by id.
backendstringBind this route to a declared backend by id.
onStatusCodesstring | number[]Override the default successful-response range.
unmeteredbooleanClear all inherited and explicit metering for this route.
inheritDefaultMetersbooleanfalse disables inherited product defaults for this route only.
@Meter("api_credits", { unit: "credit", routeDefault: 2 })
credits!: unknown;

@Meter("tokens_used", { unit: "token", estimate: 500 })
tokens!: unknown;

@Feature("runs", {
  routes: {
    "POST /v1/runs": {
      cost: { api_credits: 10 },            // 2 (routeDefault) + 10 = 12
      reports: "tokens_used",               // upstream reports actual tokens
      estimates: { tokens_used: 750 },      // admission estimate for this route
    },
    "GET /healthz": { unmetered: true },     // never metered
    "GET /status": { inheritDefaultMeters: false },  // no inherited requests/credits
  },
})
runs!: unknown;

POST /v1/runs compiles to:

{ "defaults": { "api_credits": 12, "requests": 1 },
  "reports": ["tokens_used"],
  "estimates": { "tokens_used": 750 } }

GET /healthz carries unmetered: true and no metering; GET /status carries inheritDefaultMeters: false and no metering.

A meter cannot be both a fixed cost and a dynamic reports on the same route — that conflict throws at decoration time (meter "…" cannot be both a fixed route cost and a dynamic report). A route estimates entry must also be listed in reports, and its meter must be declared, or the build fails.

onStatusCodes

By default only successful responses are metered. Override the range per route with a gateway-parseable string range or an explicit list:

"POST /v1/runs":   { cost: { api_credits: 1 }, onStatusCodes: "200-299,304" },
"POST /v1/import": { onStatusCodes: [200, 201, 202] },

A non-parseable value like "2xx" throws. (Whether 4xx counts toward request metering at the product level is the @Product({ billOn4xx }) switch — see the @Product class.)

Actions

actions declare typed operations bound to routes by id. An action's resource effect (create / delete) feeds an action_inferred resource count; subject identifies the target (e.g. from a path param); audit controls audit logging.

actions: [
  { id: "cron-job.create", kind: "mutation", title: "Create cron job",
    resource: { resource: "cron_jobs", effect: "create" } },
  { id: "cron-job.delete", kind: "mutation", title: "Delete cron job",
    subject: { type: "cron_job", from: "path_param", name: "id" },
    resource: { resource: "cron_jobs", effect: "delete" }, audit: "full" },
],
routes: {
  "POST /v1/cron-jobs": { action: "cron-job.create" },
  "DELETE /v1/cron-jobs/{id}": { action: "cron-job.delete" },
},

Action ids must be unique across all features; a duplicate id throws at decoration time. The create action then increments the cron_jobs count and the delete action decrements it, so a plan capping cron_jobs (via capability grants) is enforced without any backend counting code.

Non-HTTP work: @Workflow

Routes are HTTP. For non-HTTP product workflows (scheduled jobs, agent tasks, async jobs), use the @Workflow member instead — it declares a trigger, the capabilities/meters it consumes, and estimates, and compiles to product.workflows, separate from routes:

@Workflow("run_agent", {
  title: "Run agent",
  kind: "agent_task",
  trigger: { type: "api", path: "/v1/agent/runs" },
  capabilities: ["agent_access"],
  meters: ["workflow_runs"],
  estimates: { workflow_runs: 1 },
})
runAgent!: unknown;