doc/contributing-apis.md
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, /drivers/* methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — AGENTS.md defers to it for API work.
Companion docs: architecture.md (backend layering), pagination.md (list APIs), src/puter-js/tests/api/README.md (SDK test environment).
puter.js is served live from https://js.puter.com/v2/ with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
The first decision is where the API lives.
If the API is not crucial to core functionality — nothing in core will call it — prefer an extension over wiring it into core. Extensions live in extensions/, parallel the core layered stack, and reach core through extension.import(...):
const services = extension.import('service');
const stores = extension.import('store');
extension.registerDriver('myFeature', MyFeatureDriver); // first-class driver
extension.post('/my-feature/frobnicate', opts, handler); // or plain routes
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight extension.get/post/... helpers are enough on their own.
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see whoami for the cautionary example of a load-bearing "extension".
Work through all seven steps; the PR is complete when every one is.
camelCase. (Existing snake_case names stay where they already exist.)limit/cursor in, { items, cursor, total } envelope out.Follow the layered stack (architecture.md): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable snake_case error codes.
Both are supported ways to define an API. Prefer a controller when you need fine-grained control — URL shape, HTTP verbs, per-route gates, response and streaming formats. A driver fits when the API is a set of RPC methods implementing one of the named interfaces on /drivers/* (puter-kvstore, puter-chat-completion, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
PuterController (src/backend/controllers/types.ts) and declare routes with the @Controller(prefix) class decorator plus @Get/@Post/… method decorators (src/backend/core/http/decorators.ts), each taking (path, routeOptions) — or override registerRoutes(router) imperatively. Core controllers register in src/backend/controllers/index.ts; extensions use extension.registerController(...) or the plain route helpers.PuterDriver (src/backend/drivers/types.ts) and mark it with the @Driver(interfaceName, opts) decorator (src/backend/drivers/decorators.ts), which also declares its policies. Core drivers register in src/backend/drivers/index.ts; extensions use extension.registerDriver(...).RouteOptions: auth gates (requireAuth, requireUserActor, noUserSession, adminOnly, allowedAppIds, and the access-token controls), subdomain routing, per-route rateLimit, body parsers, and arbitrary extra middleware. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.@Driver options: per-method rateLimit (limit/window/backend), concurrent in-flight caps (optionally bySubscription), requireSubscription, and noUserSession. The /drivers/call surface enforces them.requireSubscription — the route option on a controller endpoint, the per-method @Driver({ requireSubscription }) block on a driver (/drivers/call is one shared route, so a driver's requirement can't live in route options). true accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (['business', 'pro']) accepts only those; false is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from requireCredits: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with meteringEnforcement.subscriptions: false.requireVerified (email, and only under strict_email_verification_required), requirePhoneVerified, requireCardVerified. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.requireCredits: true, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call assertActorHasCredits themselves (src/backend/services/metering/enforcement.ts) — see KVStoreDriver, which does it once for every method.{ message, code } objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.@param/@returns on the implementation, one @overload block per accepted call form, and @typedef {Object} + @property for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand.types.js (e.g. src/puter-js/src/modules/kv/types.js); anything shared across modules goes in src/puter-js/src/lib/types.js. A shape with one consumer can stay next to it.npm run check:puterjs:types — it generates the declarations and type-checks the published surface without skipLibCheck. Never edit anything under src/puter-js/types/: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it.<Area>/<method>.md — frontmatter (title, description, platforms), syntax, parameters, return value, and at least one runnable example — and update the area overview (<Area>.md). Copy the structure of an existing page.setupPuterTestEnv in src/backend/testUtil.ts) over mocking.<area>.suite.ts (register new suites in suites/index.ts). One suite runs on node, browser, and workerd via npm run test:puterjs — never write per-platform tests, and rebuild first with npm run build:workerLib since the runners execute the built bundle.puter.ui.*): add a Playwright spec per src/puter-js/TESTING.md.Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
Changes are additive by default:
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
The old surface keeps working. Mark it @deprecated in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
{ message, code } with stable codes