Skip to main content
app/remote/posts.py
Write a function in app/remote/*.py, and you can call it from $remote/<module> on the client. No route file, no fetch call, no hand-written JSON schema required. This is Fymo’s remote functions layer, modeled directly on SvelteKit’s own approach. A Python function is the endpoint, and the client you call it through gets generated for you at build time.

What gets exposed

By default, every public function in an app/remote/*.py module is callable from the browser, as long as it’s type-annotated. Public just means the name doesn’t start with an underscore, and the same rule applies to the file itself: a module named _helpers.py is skipped entirely. Only functions actually defined in a module are scanned, never ones imported in from elsewhere. Every parameter needs a type annotation. If one is missing, the build fails right away, instead of shipping a function that would break unpredictably later:
app/remote/bad.py
This “exposed by default” behavior is handy for small apps, but it also means a helper function living next to real endpoints in the same file becomes silently callable, unless you underscore-prefix it. Fymo controls this with remote.mode in fymo.yml:
fymo.yml
  • strict: only functions decorated with @remote are discovered and dispatched. Everything else in the module is a private helper, whether or not it’s underscored. There’s nothing left for the build’s hygiene check to flag, since anything undecorated is already unreachable.
  • implicit-legacy: every public, type-annotated function dispatches, the same behavior described above, and the hygiene check is silenced too. It exists as a migration aid for older projects and is explicitly unsafe as a long-term setting, not something to reach for on a new app.
When remote: is absent from fymo.yml entirely, the default is implicit exposure, but the build still runs the hygiene check, and it fails if it finds an unmarked function that would be implicitly exposed. So the zero-config state isn’t silently unsafe: it nudges you toward adding @remote markers or moving to mode: strict outright, rather than exposing everything with no warning. A freshly scaffolded app (fymo new) gets mode: strict set explicitly in its fymo.yml, so this default only comes up for projects that predate the setting or removed the line.
app/remote/posts.py
The @remote decorator itself doesn’t wrap or change the function, it just stamps __fymo_remote__ = True on it. Under implicit-legacy or the zero-config default, the decorator does nothing for exposure, since everything is already exposed. It’s still worth applying consistently, though, so a module reads the same way no matter which mode a project runs in.
An older explicit_optin/allow_implicit boolean pair predates remote.mode and is still accepted as a deprecated fallback, but remote.mode is the interface to write against now. Setting mode: alongside either deprecated key is a configuration error, not a way to combine them.
Discovery and the request router both call the same is_exposed_remote_fn() check, so what the generated client can reach and what the server will actually dispatch never drift apart.

The generated client

Running the Fymo build walks every module in app/remote/*.py. For each one, it writes a matching .js file, the fetch wrapper, and a .d.ts file with the types. Both land under dist/client/_remote/. The path $remote/posts resolves to that generated pair. Importing from it in a Svelte component gives you real autocomplete and real return types, not any:
Calling one of these functions sends a POST request to /_fymo/remote/<hash>/<fn> under the hood. The hash is a content hash of the source module, so a stale cached client can never silently call the wrong version of a function after a deploy. Every call is just a plain fetch, so it behaves like any other async call in your component. You can await it, wrap it in a try/catch, or race it, just like you’d do with anything else. Because the import path resolves to compiled JS with no server-only code in it, it’s also safe to import lazily and keep out of your SSR bundle entirely:
app/lib/auth.ts
Fymo also lets a controller thread a remote function through as a prop, instead of importing it on the client. getContext() can hand create_comment straight to a component. The server replaces it with a marker during server-side rendering, and the client runtime swaps that marker for a real fetch wrapper once the page hydrates. That’s covered on the app/ directory page; this page focuses on calling $remote/* directly.

The wire format

Request and response bodies aren’t plain JSON. They’re encoded with devalue, the same tagged-JSON format SvelteKit uses for its own form actions and load functions. Fymo ships a Python port that’s wire-compatible with the JS package. Plain JSON can’t represent a Date, a Set, or a repeated reference, not without you writing custom serialization code for every case. devalue can. Here’s what Fymo’s implementation currently round-trips:

Date / datetime

Python date and datetime values encode as ["Date", isoformat], then decode back into real date objects, never plain strings.

Set / frozenset

Encoded as ["Set", ...], then decoded back into a real Python set or frozenset.

NaN / Infinity / -0

Encoded as small sentinel numbers, so these special values survive a JSON round trip instead of turning into null or throwing.

Shared references

A value that appears twice in the same payload gets encoded once, then referenced by slot index the second time. Cycles are handled too.
The sentinels behind NaN, Infinity, and -0 are the numbers -3, -4, -5, and -6. That’s how they survive a JSON round trip cleanly instead of collapsing into null.A few more Python types round-trip too:
  • Decimal encodes as a number
  • UUID encodes as a string
  • bytes encodes as base64
  • Enum encodes as its value
  • Pydantic models are flattened field-by-field
On the way in, validation goes further than simple type coercion. If a parameter is typed as a TypedDict, a dataclass, a NamedTuple, or a Pydantic model, the incoming payload is checked against that exact shape instead of just being passed through.

Pagination helpers

Fymo ships small pagination helpers for remote functions that return a list scoped to a page: encode_cursor, decode_cursor, and paginate, all importable from fymo.remote. The convention is fetch-limit-plus-one: query for one more row than the page size, then let paginate split that extra row off into a next_cursor.
app/remote/posts.py
A cursor is opaque to the client. encode_cursor packs one or more sort-key values into base64url-encoded JSON, and decode_cursor reverses it. Sorting by published_at alone isn’t safe here, since two posts can share a timestamp, so the cursor above carries (published_at, slug) together, and decode_cursor(cursor, expect=2) enforces that exact shape coming back in. Malformed input, anything from broken base64 to a tampered value outside JavaScript’s safe integer range, raises a 400 bad_cursor error rather than a 500. A client never needs to parse or construct a cursor itself, it just passes back whatever next_cursor it was handed on the previous page. paginate(rows, limit, key=...) takes the rows you fetched (up to limit + 1 of them), keeps the first limit, and, if an extra row came back, encodes that row’s sort key as next_cursor. On the last page, next_cursor comes back None.

Rate limiting

@rate_limit(per_minute, scope="ip") gives a single remote function its own token-bucket budget, separate from limits.rate_limit, the path-prefix limiter that already covers every request under /_fymo/remote/. Both stack: the middleware’s budget still applies at the edge, and the per-function budget applies on top of it. That’s useful when one function is expensive, an LLM call or a paid third-party API, while its module neighbors are cheap reads.
app/remote/posts.py
scope decides what counts as one caller:

ip

The client’s resolved IP address. The default, and the only scope that doesn’t depend on identity.

user

The uid from the @identify resolver chain, falling back to the verified fymo_uid cookie, then the IP.

uid

The verified fymo_uid cookie only, falling back to the IP when the cookie is missing or fails verification.
A caller past their budget gets a RateLimited error, the same RemoteError machinery as NotFound or Conflict uses: status 429, code rate_limited, plus a retry_after field giving the number of seconds until the next attempt might succeed. There’s no fymo.yml surface for this, the limit is configured right next to the function it protects.

Error handling

A remote function that raises propagates to the client as a structured error rather than a generic 500. Fymo defines a small hierarchy for this in fymo.remote:
Any of these, or a custom RemoteError(message, status=..., code=...), gets caught by the router before it turns into a generic failure. It comes back as a JSON envelope with three fields:
  • status: the HTTP status code
  • error: a short machine-readable code
  • message: a human-readable description
An exception that isn’t a RemoteError still gets caught. It just always reports status 500 with error “internal”. In dev mode, the response also includes the exception message and a traceback; both get stripped out in production. On the client, the generated wrapper throws a real Error for any of these. It attaches a few extra properties you can check:
  • status: the HTTP status code
  • error: the short error code
  • issues: present only for validation failures, an array pointing at the field that failed
A component can branch on these the way Comments.svelte does in the example blog app:
A failed Pydantic validation, meaning a bad argument shape rather than a raised RemoteError, comes back differently. It reports status 422 with error "validation", plus an issues array pointing at the specific field that failed.

Server-driven redirects

Raising Redirect from a remote function sends the client somewhere else instead of returning a result. It’s still a RemoteError subclass, so it travels the same catch path as NotFound or Unauthorized, but the router treats it specially: instead of an error envelope, the response comes back as {"type": "redirect", "location": "..."}, and the generated client navigates there automatically, rather than throwing.
app/remote/redirect_demo.py
The default status is 303. Pass a different one with Redirect(location, status=...) if a call site needs it. The same Redirect class also works when raised from a controller’s getContext() during server-side rendering, where it produces a real HTTP redirect with a Location header instead of a JSON envelope; that side of it belongs on the app/ directory page. Here, the point is that a remote function can end a call by sending the client elsewhere, not just by returning data or raising an error.

CSRF protection

The remote endpoint at /_fymo/remote/<hash>/<fn> only accepts POST requests. That alone rules out the classic cookie-riding GET attack, where a plain <img src="..."> tag or a top-level navigation carries your cookies along for the ride. Those requests never carry an Origin header, so restricting to POST closes that path outright. For POST requests, the router additionally checks that the Origin header, when present, matches the request’s Host. A cross-origin fetch or form POST always sends an Origin header, so a mismatched one gets rejected with 403 cross_origin before your function ever runs. Same-origin requests pass through, including ones with no Origin header at all, which browsers omit for some same-origin cases.
This check runs whether or not you’ve enabled Fymo’s auth module. It’s the baseline protection for every remote function, not something you opt into per endpoint.
Alongside CSRF protection, every call carries a fymo_uid cookie, an anonymous identity token issued on your first request. It’s not an authentication credential by itself, but it gives you a stable, unforgeable handle, useful for things like deduping a reaction or attributing an anonymous comment. Call current_uid() from inside a remote function to read it:
The cookie is HMAC-signed and verified on every request. A tampered or unsigned cookie is treated as absent, and a fresh one gets issued in its place, so there’s no way to forge a uid you don’t already hold.
For real authentication on top of that, see Fymo’s auth module, layered independently on the same request.
Inside a remote function you also get request_event(), returning the current uid, remote address, cookies, and headers for that call. It only works while a request is actually in flight, calling it outside a remote-function request raises.