> ## Documentation Index
> Fetch the complete documentation index at: https://fymo.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Remote Functions

> Call type-annotated Python functions directly from Svelte components over a typed, devalue-encoded RPC layer.

```python app/remote/posts.py theme={null}
from typing import TypedDict
from fymo.remote import NotFound

class Post(TypedDict):
    slug: str
    title: str
    content_html: str

def get_post(slug: str) -> Post:
    row = get_db().fetchone("SELECT * FROM posts WHERE slug = ?", [slug])
    if not row:
        raise NotFound(f"post '{slug}' not found")
    return row
```

```svelte theme={null}
<script lang="ts">
  import { get_post } from '$remote/posts';
  import type { Post } from '$remote/posts';

  let post: Post = await get_post('hello-world');
</script>
```

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:

```python app/remote/bad.py theme={null}
def fn(x):  # ValueError at discovery time: please annotate parameter 'x'
    return x
```

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`:

```yaml fymo.yml theme={null}
remote:
  mode: strict
```

* `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.

```python app/remote/posts.py theme={null}
from fymo.remote import remote

@remote
def get_posts() -> list[PostSummary]:
    ...

def _format_summary(row):  # never reachable either way
    ...

def helper_used_by_get_posts():  # NOT exposed once mode is strict,
    ...                          # even without decoration
```

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.

<Note>
  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.
</Note>

<Note>
  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.
</Note>

## 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`:

```ts theme={null}
// dist/client/_remote/posts.d.ts (generated, do not edit)
export interface PostSummary {
  slug: string;
  title: string;
  published_at: string;
}
export function get_posts(): Promise<PostSummary[]>;
export function get_post(slug: string): Promise<Post>;
```

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:

```ts app/lib/auth.ts theme={null}
const client = () => import('$remote/auth');

export async function login(email: string, password: string) {
  const u = await (await client()).login(email, password);
  user.set(u);
  return u;
}
```

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](/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](https://github.com/Rich-Harris/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:

<CardGroup cols={2}>
  <Card title="Date / datetime" icon="calendar">
    Python date and datetime values encode as `["Date", isoformat]`, then decode back into real date objects, never plain strings.
  </Card>

  <Card title="Set / frozenset" icon="brackets-curly">
    Encoded as `["Set", ...]`, then decoded back into a real Python set or frozenset.
  </Card>

  <Card title="NaN / Infinity / -0" icon="infinity">
    Encoded as small sentinel numbers, so these special values survive a JSON round trip instead of turning into `null` or throwing.
  </Card>

  <Card title="Shared references" icon="link">
    A value that appears twice in the same payload gets encoded once, then referenced by slot index the second time. Cycles are handled too.
  </Card>
</CardGroup>

<Accordion title="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:

  * `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
</Accordion>

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`.

```python app/remote/posts.py theme={null}
from fymo.remote import remote, decode_cursor, paginate

class PostsPage(TypedDict):
    items: list[PostSummary]
    next_cursor: str | None

@remote
def list_posts(cursor: str | None = None, limit: int = 20) -> PostsPage:
    limit = max(1, min(limit, 50))
    fields = "slug, title, summary, tags, published_at"
    if cursor:
        published_at, slug = decode_cursor(cursor, expect=2)
        rows = get_db().fetchall(
            f"SELECT {fields} FROM posts WHERE (published_at, slug) < (?, ?) "
            "ORDER BY published_at DESC, slug DESC LIMIT ?",
            [published_at, slug, limit + 1],
        )
    else:
        rows = get_db().fetchall(
            f"SELECT {fields} FROM posts ORDER BY published_at DESC, slug DESC LIMIT ?",
            [limit + 1],
        )
    return paginate(rows, limit, key=lambda p: (p["published_at"], p["slug"]))
```

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.

```python app/remote/posts.py theme={null}
from fymo.remote import remote, rate_limit

@remote
@rate_limit(per_minute=5, scope="user")
def generate_summary(slug: str) -> str:
    ...
```

`scope` decides what counts as one caller:

<CardGroup cols={3}>
  <Card title="ip" icon="globe">
    The client's resolved IP address. The default, and the only scope that doesn't depend on identity.
  </Card>

  <Card title="user" icon="user">
    The uid from the `@identify` resolver chain, falling back to the verified `fymo_uid` cookie, then the IP.
  </Card>

  <Card title="uid" icon="fingerprint">
    The verified `fymo_uid` cookie only, falling back to the IP when the cookie is missing or fails verification.
  </Card>
</CardGroup>

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`:

```python theme={null}
from fymo.remote import RemoteError, NotFound, Unauthorized, Forbidden, Conflict

raise NotFound(f"post '{slug}' not found")   # 404, error: "not_found"
raise Unauthorized("sign in first")          # 401, error: "unauthorized"
raise Forbidden("not your comment")          # 403, error: "forbidden"
raise Conflict("slug already taken")         # 409, error: "conflict"
```

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:

```ts theme={null}
try {
  const c = await create_comment(slug, { body });
} catch (err: any) {
  if (err.status === 401) user.set(null);
  error = err.issues?.[0]?.msg ?? err.message ?? 'Submission failed';
}
```

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.

```python app/remote/redirect_demo.py theme={null}
from fymo.remote import remote, Redirect

@remote
def go_to_login() -> None:
    raise Redirect("/login")
```

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](/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.

<Accordion title="The exact origin check">
  ```python theme={null}
  def _origin_ok(environ: dict) -> bool:
      origin = environ.get("HTTP_ORIGIN")
      if not origin:
          return True
      host = environ.get("HTTP_HOST")
      ...
      return origin == expected
  ```
</Accordion>

<Warning>
  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.
</Warning>

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:

```python theme={null}
from fymo.remote import current_uid

def toggle_reaction(slug: str, kind: ReactionKind) -> ReactionCounts:
    uid = current_uid()
    ...
```

<Accordion title="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.
</Accordion>

For real authentication on top of that, see Fymo's auth module, layered independently on the same request.

<Tip>
  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.
</Tip>
