app/remote/posts.py
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 anapp/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
remote.mode in fymo.yml:
fymo.yml
strict: only functions decorated with@remoteare 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.
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
@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 inapp/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:
/_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
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 aDate, 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.
Exact wire encoding details
Exact wire encoding details
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:Decimalencodes as a numberUUIDencodes as a stringbytesencodes as base64Enumencodes as its value- Pydantic models are flattened field-by-field
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
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.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 infymo.remote:
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 codeerror: a short machine-readable codemessage: a human-readable description
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 codeerror: the short error codeissues: present only for validation failures, an array pointing at the field that failed
Comments.svelte does in the example blog app:
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
RaisingRedirect 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
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.
The exact origin check
The exact origin check
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:
How fymo_uid is signed and verified
How fymo_uid is signed and verified
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.
