---
title: Let visitors call an operation
description: 'Open one operation, such as a contact form or an RSVP, to people who are not members of your App, while the rest of the App stays closed.'
sidebar:
  label: Let visitors call an operation
  order: 7
---

## Open one operation, not the whole App

A public App lets anyone read it, but only its members can change anything. Some Apps need one exception: a contact form, a booking request, an RSVP, a guestbook entry. Declare that exception on the route itself with `callers`, and every other operation stays members-only.

`callers` is part of the `2026-10-16` App contract. Declare `$schema: "https://charm.ing/schema/app-manifest/2026-10-16.json"` in the manifest. Apps on `2026-07-31` ignore the key, and saving one that declares it returns a warning naming the URL to switch to.

```js
export const manifest = {
  $schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
  id: 'enquiries',
  meta: { name: 'Enquiries' },
  capabilities: { imports: ['charming:storage/kv@1.0'] },
};

export const routes = [
  {
    op: 'submitEnquiry',
    callers: 'anyone',
    inputSchema: {
      type: 'object',
      required: ['email', 'message'],
      properties: { email: { type: 'string' }, message: { type: 'string' } },
      additionalProperties: false,
    },
    handler: async (input, { env }) => {
      const at = new Date().toISOString();
      await env.storage.put(`enquiry:${at}:${crypto.randomUUID()}`, { ...input, at });
      return { received: true };
    },
  },
  {
    op: 'listEnquiries',
    handler: async (_input, { env }) => {
      const keys = (await env.storage.list()).filter((key) => key.startsWith('enquiry:'));
      return Promise.all(keys.map((key) => env.storage.get(key)));
    },
  },
];
```

## Who can call it

| `callers` | Who may call the operation |
| --- | --- |
| `'members'` (default) | People who can run the App through its access setting, a share, or team membership |
| `'signed-in'` | Also anyone signed in to Charming who can view the App |
| `'anyone'` | Also a visitor with no account |

The declaration only widens an operation for someone who can already view the App. Make the App Public, or open it to signed-in visitors, in its Access settings. A private App stays closed to visitors whatever its routes declare.

A route that declares `'signed-in'` or `'anyone'` must stay public, so `public: false` together with either value is refused when you save.

## What the handler does

The handler decides what a visitor can do, and it runs against the App's own data, the same data its members see. On a confirmed [custom domain](/docs/capabilities/custom-domains), an anonymous visitor's calls use that domain's per-visitor storage instead, so what they submit there stays in their own storage and members do not see it. In the example, `submitEnquiry` writes one new key per enquiry and nothing else, so two visitors who submit at once never overwrite each other, and only members can call `listEnquiries`, so only they read the enquiries back.

An operation with `annotations.readOnlyHint: true` is open to everyone who can view the App, whatever `callers` says, and `public: false` only hides a route from discovery. Never mark an operation read-only when it returns what visitors submitted.

`env.user` is the caller's public identity for a signed-in caller and `null` for a visitor with no account. Validate visitor input with `inputSchema`, and keep what the handler stores small and specific to the operation.

Every call the declaration admits counts as a Run against the owner's plan, like any other call. Callers who are not members are limited per address when signed out and per account when signed in, and together they get only a share of the App's run bounds, so a flood of visitors never locks members out. A call those limits refuse answers 429 `visitor_rate_limited`, does not run, and is not a Run. See [Limits](/docs/capabilities/limits#callers-who-are-not-members) for the numbers.

## Ask for an account only when the operation needs one

When a visitor without an account calls a `'signed-in'` operation, Charming answers `sign_in_required`. Wrap the call in `window.charming.withUser(action)`, and the visitor signs in in a popup and the call runs again. See [Gate an action behind sign-in](/docs/guides/gate-an-action-behind-sign-in) for the full pattern.

`window.charming.viewer.can(op)` returns `true` for a declared operation, because Charming decides on the server whether this visitor qualifies. The one exception is a `'signed-in'` operation where `window.charming.login.available` is `false`, as in a chat host's embed: it returns `false` there, because no sign-in can run.

Inside a chat host's embed, a signed-in visitor's calls reach the App without their account, so a `'signed-in'` operation answers `sign_in_required` there even when `window.charming.user` names them. Offer that operation on the App's own page, at `window.charming.login.canonicalUrl`, rather than inside the embed.

## Related

- [Privacy and sharing](/docs/capabilities/privacy-and-sharing): the access settings that decide who can view an App
- [Gate an action behind sign-in](/docs/guides/gate-an-action-behind-sign-in): sign a visitor in from inside the App

Found a bug or need a feature? [Tell us](/docs/capabilities/feedback) with `submit_feedback` or `POST /app/{id}/feedback`. Your feedback shapes what we build next.
